… как наполнить шаблонный класс разным содержимым в зависимости от значений параметров шаблона?


Когда-то, уже довольно давно, язык D начали делать как "правильный C++" с учетом накопившегося в C++ опыта. Со временем D стал не менее сложным и более выразительным языком, чем C++. И уже C++ стал подсматривать за D. Например, появившийся в C++17 if constexpr, на мой взгляд, — это прямое заимствование из D, прототипом которому послужил D-шный static if.


К моему сожалению, if constexpr в С++ не обладает такой же мощью, как static if в D. Тому есть свои причины, но все-таки бывают случаи, когда остается только пожалеть, что if constexpr в C++ не позволяет управлять наполнением C++ного класса. Об одном из таких случаев и хочется поговорить.


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


Задача, которую требуется решить


Требуется создать хитрый вариант "умного указателя" для хранения объектов-сообщений. Чтобы можно было написать что-то вроде:


message_holder_t<my_message> msg{ new my_message{...} };
send(target, msg);
send(another_target, msg);

Хитрость этого класса message_holder_t в том, что нужно учесть три важных фактора.


От чего отнаследован тип сообщения?


Типы сообщений, которыми параметризуется message_holder_t, делятся на две группы. Первая группа — это сообщения, которые наследуются от специального базового типа message_t. Например:


struct so5_message final : public so_5::message_t {
   int a_;
   std::string b_;
   std::chrono::milliseconds c_;

   so5_message(int a, std::string b, std::chrono::milliseconds c)
      : a_{a}, b_{std::move(b)}, c_{c}
   {}
};

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


template<typename M>
class message_holder_t {
   intrusive_ptr_t<M> m_msg;
public:
   ...
   const M * get() const noexcept { return m_msg.get(); }
};

Вторая группа — это сообщения произвольных пользовательских типов, которые не наследуются от message_t. Например:


struct user_message final {
   int a_;
   std::string b_;
   std::chrono::milliseconds c_;

   user_message(int a, std::string b, std::chrono::milliseconds c)
      : a_{a}, b_{std::move(b)}, c_{c}
   {}
};

Экземпляры таких типов в SObjectizer-е отсылаются не сами по себе, а заключенными в специальную обертку user_type_message_t<M>, которая уже наследуется от message_t. Поэтому для таких типов message_holder_t должен содержать внутри себя указатель на user_type_message_t<M>, а методы-getter-ы должны возвращать указатель на M:


template<typename M>
class message_holder_t {
   intrusive_ptr_t<user_type_message_t<M>> m_msg;
public:
   ...
   const M * get() const noexcept { return std::addressof(m_msg->m_payload); }
};

Иммутабельность или мутабельность сообщений


Второй фактор — это деление сообщений на неизменяемые (immutable) и изменяемые (mutable). Если сообщение неизменяемое (а по умолчанию оно неизменяемое), то методы-getter-ы должны возвращать константный указатель на сообщение. А если изменяемое, то getter-ы должны возвращать не константный указатель. Т.е. должно быть что-то вроде:


message_holder_t<so5_message> msg1{...}; // Неизменяемое сообщение.
const int a = msg1->a_; // OK.
msg1->a_ = 0; // ТУТ ДОЛЖНА БЫТЬ ОШИБКА КОМПИЛЯЦИИ!

message_holder_t<mutable_msg<user_message>> msg2{...}; // Изменяемое сообщение.
const int a = msg2->a_; // OK.
msg2->a_ = 0; // OK.

shared_ptr vs unique_ptr


Третий фактор — это логика поведения message_holder_t как умного указателя. Когда-то он должен вести себя как std::shared_ptr, т.е. можно иметь несколько message_holder-ов, ссылающихся на один и тот же экземпляр сообщения. А когда-то он должен вести себя как std::unique_ptr, т.е. только один экземпляр message_holder-а может ссылаться на экземпляр сообщения.


По умолчанию, поведение message_holder_t должно зависеть от изменяемости/неизменяемости сообщения. Т.е. с неизменяемыми сообщениями message_holder_t должен вести себя как std::shared_ptr, а с изменяемыми, как std::unique_ptr:


message_holder_t<so5_message> msg1{...};
message_holder_t<so5_message> msg2 = msg; // OK.

message_holder_t<mutable_msg<user_message>> msg3{...};
message_holder_t<mutable_msg<user_message>> msg4 = msg3; // БУМС! Так нельзя!
message_holder_t<mutable_msg<user_message>> msg5 = std::move(msg3); // OK.

Но жизнь штука сложная, поэтому нужно иметь еще и возможность вручную задать поведение message_holder_t. Чтобы можно было сделать message_holder-а для иммутабельного сообщения, который ведет себя как unique_ptr. И чтобы можно было сделать message_holder-а для изменяемого сообщения, который ведет себя как shared_ptr:


using unique_so5_message = so_5::message_holder_t<
   so5_message,
   so_5::message_ownership_t::unique>;

unique_so5_message msg1{...};
unique_so5_message msg2 = msg1; // БУМС! Так нельзя!
unique_so5_message msg3 = std::move(msg); // OK, сообщение в msg3.

using shared_user_messsage = so_5::message_holder_t<
   so_5::mutable_msg<user_message>,
   so_5::message_ownership_t::shared>;

shared_user_message msg4{...};
shared_user_message msg5 = msg4; // OK.

Соответственно, когда message_holder_t работает как shared_ptr, у него должен быть обычный набор конструкторов и операторов присваивания: и копирования, и перемещения. Кроме того, должен быть константный метод make_reference, который возвращает копию хранящегося внутри message_holder_t указателя.


А вот когда message_holder_t работает как unique_ptr, то конструктор и оператор копирования у него должны быть запрещены. А метод make_reference должен изымать указатель у объекта message_holder_t: после вызова make_reference исходный message_holder_t должен остаться пустым.


Чуть более формально


Итак, нужно создать шаблонный класс:


template<
   typename M,
   message_ownership_t Ownership = message_ownership_t::autodetected>
class message_holder_t {...};

у которого:


  • внутри должен храниться intrusive_ptr_t<M> или intrusive_ptr<user_type_message_t<M>> в зависимости от того, наследуется ли M от message_t;
  • методы-getter-ы должны возвращать либо const M*, либо M* в зависимости от изменяемости/неизменяемости сообщения;
  • должен быть либо полный набор конструкторов и операторов копирования/перемещения, либо только конструктор и оператор перемещения;
  • метод make_reference() должен либо возвращать копию хранимого intrusive_ptr, либо должен изымать значение intrusive_ptr и оставлять исходный message_holder_t в пустом состоянии. В первом случае make_reference() должен быть константным, во втором — неконстантным методом.

Последние два пункта из перечня определяются параметром Ownership (а также мутабельностью сообщения, если для Ownership используется значение autodetected).


Как это было решено


В данном разделе мы рассмотрим все составляющие, из которых получилось итоговое решение. Ну и само результирующее решение. Будут показаны максимально очищенные от всех отвлекающих внимание деталей фрагменты кода. Если кого-то интересует реальный код, то увидеть его можно здесь.


Disclaimer


Показанное ниже решение не претендует на красоту, идеальность или образец для подражания. Оно было найдено, реализовано, протестировано и задокументированно за небольшое время, под давлением сроков. Возможно, если бы времени было больше, и поиском решения занимался более молодой, толковый и сведующий в современном C++ разработчик, то оно получилось бы компактнее, проще и понятнее. Но, как получилось, так и получилось… "Don't shoot the pianist", в общем.


Последовательность шагов и уже готовая шаблонная магия


Итак, нам нужно иметь класс с несколькими наборами методов. Содержимое этих наборов должно откуда-то взяться. Откуда?


В языке D мы могли бы воспользоваться static if и определить разные части класса в зависимости от разных условий. В каком-нибудь Ruby мы могли бы подмешать методы в свой класс посредством метода include. Но мы в C++, в котором пока наши возможности сильно ограничены: мы можем либо определить метод/атрибут прямо внутри класса, либо можем унаследовать метод/атрибут из какого-то базового класса.


Определить разные методы/атрибуты внутри класса в зависимости от какого-то условия мы не можем, т.к. C++ный if constexpr — это не D-шный static if. Следовательно, остается только наследование.


Upd. Как мне подсказали в комментариях, тут следует высказаться более осторожно. Поскольку в C++ есть SFINAE, то мы посредством SFINAE можем включать/выключать видимость отдельных методов в классе (т.е. достигать эффекта, аналогичного static if-у). Но у такого подхода есть два серьезных, на мой взгляд, недостатка. Во-первых, если таких методов не 1-2-3, а 4-5 или больше, то оформлять каждый из них посредством SFINAE утомительно, да и на читабельности кода это сказывается. Во-вторых, SFINAE не помогает нам добавлять/изымать атрибуты (поля) класса.

В C++ мы можем определить несколько базовых классов, от которых мы затем отнаследуем message_holder_t. А выбор того или иного базового класса уже будем делать в зависимости от значений параметров шаблона, посредством std::conditional.


Но фокус в том, что нам потребуется не просто набор базовых классов, а небольшая цепочка наследования. В ее начале будет класс, который будет определять общую функциональность, которая потребуется в любом случае. Далее будут базовые классы, которые будут определять логику поведения "умного указателя". А уже затем будет класс, который определит нужные getter-ы. В таком порядке мы и рассмотрим реализованные классы.


Нашу задачу упрощает то, что в SObjectizer-е уже есть готовая шаблонная магия, которая определяет, наследуется ли сообщение от message_t, а также средства для проверки мутабельности сообщений. Поэтому в реализации мы эту готовую магию будем просто использовать и не станем погружаться в детали ее работы.


Общая база для хранения указателя


Начнем с общего базового типа, который хранит соответствующий intrusive_ptr, а также предоставляет общий набор методов, которые нужны любой из реализаций message_holder_t:


template< typename Payload, typename Envelope >
class basic_message_holder_impl_t
   {
   protected :
      intrusive_ptr_t< Envelope > m_msg;

   public :
      using payload_type = Payload;
      using envelope_type = Envelope;

      basic_message_holder_impl_t() noexcept = default;

      basic_message_holder_impl_t( intrusive_ptr_t< Envelope > msg ) noexcept
         :  m_msg{ std::move(msg) }
         {}

      void reset() noexcept { m_msg.reset(); }

      [[nodiscard]]
      bool empty() const noexcept { return static_cast<bool>( m_msg ); }

      [[nodiscard]]
      operator bool() const noexcept { return !this->empty(); }

      [[nodiscard]]
      bool operator!() const noexcept { return this->empty(); }
   };

У этого шаблонного класса два параметра. Первый, Payload, задает тип, который должны использовать методы-getter-ы. Тогда как второй, Envelope, задает тип для intrusive_ptr. В случае, когда тип сообщения наследуется от message_t оба эти параметра будут иметь одинаковое значение. А вот если сообщение не наследуется от message_t, тогда в качестве Payload будет тип сообщения, а в качестве Envelope будет выступать user_type_message_t<Payload>.


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


Во-первых, сам указатель, т.е. атрибут m_msg, определен в protected секции для того, чтобы классы наследники имели к нему доступ.


Во-вторых, для этого класса сам компилятор генерирует все необходимые конструкторы и операторы копирования/перемещения. И на уровне этого класса мы пока ничего не запрещаем.


Отдельные базы для shared_ptr- и unique_ptr-поведения


Итак, у нас есть класс, который хранит указатель на сообщение. Теперь мы можем определить его наследников, которые и будут вести себя либо как shared_ptr, либо как unique_ptr.


Начнем со случая shared_ptr-поведения, т.к. здесь меньше всего кода:


template< typename Payload, typename Envelope >
class shared_message_holder_impl_t
   :  public basic_message_holder_impl_t<Payload, Envelope>
   {
      using direct_base_type = basic_message_holder_impl_t<Payload, Envelope>;

   public :
      using direct_base_type::direct_base_type;

      [[nodiscard]] intrusive_ptr_t< Envelope >
      make_reference() const noexcept
         {
            return this->m_msg;
         }
   };

Ничего сложного: наследуемся от basic_message_holder_impl_t, наследуем все его конструкторы и определяем простую, неразрушающую реализацию make_reference().


Для случая unique_ptr-поведения кода побольше, хотя сложного в нем ничего нет:


template< typename Payload, typename Envelope >
class unique_message_holder_impl_t
   :  public basic_message_holder_impl_t<Payload, Envelope>
   {
      using direct_base_type = basic_message_holder_impl_t<Payload, Envelope>;

   public :
      using direct_base_type::direct_base_type;

      unique_message_holder_impl_t(
         const unique_message_holder_impl_t & ) = delete;

      unique_message_holder_impl_t(
         unique_message_holder_impl_t && ) = default;

      unique_message_holder_impl_t &
      operator=( const unique_message_holder_impl_t & ) = delete;

      unique_message_holder_impl_t &
      operator=( unique_message_holder_impl_t && ) = default;

      [[nodiscard]] intrusive_ptr_t< Envelope >
      make_reference() noexcept
         {
            return { std::move(this->m_msg) }; 
         }
   };

Опять же, наследуемся от basic_message_holder_impl_t и наследуем у него нужные нам конструкторы (это конструктор по-умолчанию и инициализирующий конструктор). Но при этом определяем конструкторы и операторы копирования/перемещения в соответствии с логикой unique_ptr: копирование запрещаем, перемещение реализуем.


Также у нас здесь разрушающий метод make_reference().


Вот, собственно, все. Осталось только реализовать выбор между двумя этими базовыми классами...


Выбор между shared_ptr- и unique_ptr-поведением


Для выбора между shared_ptr- и unique_ptr-поведением потребуется следующая метафункция (метафункция она потому, что "работает" с типами в компайл-тайм):


template< typename Msg, message_ownership_t Ownership >
struct impl_selector
   {
      static_assert( !is_signal<Msg>::value,
            "Signals can't be used with message_holder" );

      using P = typename message_payload_type< Msg >::payload_type;
      using E = typename message_payload_type< Msg >::envelope_type;

      using type = std::conditional_t<
            message_ownership_t::autodetected == Ownership,
               std::conditional_t<
                     message_mutability_t::immutable_message ==
                           message_mutability_traits<Msg>::mutability,
                     shared_message_holder_impl_t<P, E>,
                     unique_message_holder_impl_t<P, E> >,
               std::conditional_t<
                     message_ownership_t::shared == Ownership,
                     shared_message_holder_impl_t<P, E>,
                     unique_message_holder_impl_t<P, E> >
         >;
   };

Эта метафункция принимает оба параметра из списка параметров message_holder_t и в качестве результата (т.е. определения вложенного типа type) "возвращает" тип, от которого следует отнаследоваться. Т.е. либо shared_message_holder_impl_t, либо unique_message_holder_impl_t.


Внутри определения impl_selector можно увидеть следы той магии, о которой говорилось выше, и в которую мы не углублялись: message_payload_type<Msg>::payload_type, message_payload_type<Msg>::envelope_type и message_mutability_traits<Msg>::mutability.


А для того, чтобы использовать метафункцию impl_selector было проще, следом определим более короткое имя для нее:


template< typename Msg, message_ownership_t Ownership >
using impl_selector_t = typename impl_selector<Msg, Ownership>::type;

База для getter-ов


Итак, у нас уже есть возможность выбрать базу, которая содержит указатель и определяет поведение "умного указателя". Теперь нужно снабдить эту базу методами-getter-ами. Для чего нам потребуется один простой класс:


template< typename Base, typename Return_Type >
class msg_accessors_t : public Base
   {
   public :
      using Base::Base;

      [[nodiscard]] Return_Type *
      get() const noexcept
         {
            return get_ptr( this->m_msg );
         }

      [[nodiscard]] Return_Type &
      operator * () const noexcept { return *get(); }

      [[nodiscard]] Return_Type *
      operator->() const noexcept { return get(); }
   };

Это шаблонный класс, который зависит от двух параметров, но их смысл уже совсем другой. В качестве параметра Base будет выступать результат показанной выше метафункции impl_selector. Т.е. в качестве параметра Base задается базовый класс, от которого нужно отнаследоваться.


Важно отметить, что если наследование происходит от unique_message_holder_impl_t, у которого конструктор и оператор копирования запрещены, то компилятор не сможет сгенерировать конструктор и оператор копирования для msg_accessors_t. Что нам и требуется.


В качестве параметра Return_Type будет выступать тип сообщения, указатель/ссылку на который будет возвращаться getter-ами. Фокус в том, что для иммутабельного сообщения типа Msg параметр Return_Type будет иметь значение const Msg. Тогда как для мутабельного сообщения типа Msg параметр Return_Type будет иметь значение Msg. Таким образом метод get() для иммутабельных сообщений будет возвращать const Msg*, а для мутабельных — просто Msg*.


Посредством свободной функции get_ptr() решается проблема работы с сообщениями, которые не отнаследованны от message_t:


template< typename M >
M * get_ptr( const intrusive_ptr_t<M> & msg ) noexcept
   {
      return msg.get();
   }

template< typename M >
M * get_ptr( const intrusive_ptr_t< user_type_message_t<M> > & msg ) noexcept
   {
      return std::addressof(msg->m_payload);
   }

Т.е. если сообщение не наследуется от message_t и хранится как user_type_message_t<Msg>, то вызывается вторая перегрузка. А если наследуется, то первая перегрузка.


Выбор конкретной базы для getter-ов


Итак, шаблон msg_accessors_t требует два параметра. Первый вычисляется метафункцией impl_selector. Но для того, чтобы сформировать конкретный базовый тип из msg_accessors_t, нам нужно определиться со значением второго параметра. Для этого предназначена еще одна метафункция:


template< message_mutability_t Mutability, typename Base >
struct accessor_selector
   {
      using type = std::conditional_t<
            message_mutability_t::immutable_message == Mutability,
            msg_accessors_t<Base, typename Base::payload_type const>,
            msg_accessors_t<Base, typename Base::payload_type> >;
   };

Обратить внимание можно разве что на вычисление параметра Return_Type. Один из тех немногих случаев, когда east const оказывается полезен ;)


Ну и, для повышения читабельности последующего кода, более компактный вариант для работы с ней:


template< message_mutability_t Mutability, typename Base >
using accessor_selector_t = typename accessor_selector<Mutability, Base>::type;

Итоговый наследник message_holder_t


Теперь можно посмотреть на то, что же из себя представляет message_holder_t, для реализации которого потребовались все эти базовые классы и метафункции (из реализации удалена часть методов для конструирования экземпляра хранящегося в message_holder-е сообщения):


template<
   typename Msg,
   message_ownership_t Ownership = message_ownership_t::autodetected >
class message_holder_t
   :  public details::message_holder_details::accessor_selector_t<
            details::message_mutability_traits<Msg>::mutability,
            details::message_holder_details::impl_selector_t<Msg, Ownership> >
   {
      using base_type = details::message_holder_details::accessor_selector_t<
            details::message_mutability_traits<Msg>::mutability,
            details::message_holder_details::impl_selector_t<Msg, Ownership> >;

   public :
      using payload_type = typename base_type::payload_type;
      using envelope_type = typename base_type::envelope_type;

      using base_type::base_type;

      friend void
      swap( message_holder_t & a, message_holder_t & b ) noexcept
         {
            using std::swap;
            swap( a.message_reference(), b.message_reference() );
         }
   };

По сути все то, что мы разбирали выше, потребовалось для того, чтобы записать вот этот "вызов" двух метафункций:


details::message_holder_details::accessor_selector_t<
            details::message_mutability_traits<Msg>::mutability,
            details::message_holder_details::impl_selector_t<Msg, Ownership> >

Т.к. это не первый вариант, а результат упрощения и сокращения кода, то могу сказать, что компактные формы метафункций ну очень сильно уменьшают объем кода и увеличивают его понятность (если о понятности здесь вообще уместно говорить).


А что было бы, если бы...


А вот если бы в C++ if constexpr был настолько же мощен, как static if в D, то можно было бы написать что-то вроде:


Гипотетический вариант с более продвинутым if constexpr
template<
   typename Msg,
   message_ownership_t Ownership = message_ownership_t::autodetected >
class message_holder_t
   {
      static constexpr const message_mutability_t Mutability =
            details::message_mutability_traits<Msg>::mutability;

      static constexpr const message_ownership_t Actual_Ownership =
            (message_ownership_t::unique == Ownership ||
               (message_mutability_t::mutable_msg == Mutability &&
                message_ownership_t::autodetected == Ownership)) ?
            message_ownership_t::unique : message_ownership_t::shared;

   public :
      using payload_type = typename message_payload_type< Msg >::payload_type;
      using envelope_type = typename message_payload_type< Msg >::envelope_type;

   private :
      using getter_return_type = std::conditional_t<
            message_mutability_t::immutable_msg == Mutability,
            payload_type const,
            payload_type >;

   public :
      message_holder_t() noexcept = default;

      message_holder_t(
         intrusive_ptr_t< envelope_type > mf ) noexcept
         : m_msg{ std::move(mf) }
         {}

if constexpr(message_ownership_t::unique == Actual_Ownership )
   {
      message_holder_t(
         const message_holder_t & ) = delete;

      message_holder_t(
         message_holder_t && ) noexcept = default;

      message_holder_t &
      operator=( const message_holder_t & ) = delete;

      message_holder_t &
      operator=( message_holder_t && ) noexcept = default;
   }

      friend void
      swap( message_holder_t & a, message_holder_t & b ) noexcept
         {
            using std::swap;
            swap( a.m_msg, b.m_msg );
         }

      [[nodiscard]] getter_return_type *
      get() const noexcept
         {
            return get_const_ptr( m_msg );
         }

      [[nodiscard]] getter_return_type &
      operator * () const noexcept { return *get(); }

      [[nodiscard]] getter_return_type *
      operator->() const noexcept { return get(); }

if constexpr(message_ownership_t::shared == Actual_Ownership)
   {
      [[nodiscard]] intrusive_ptr_t< envelope_type >
      make_reference() const noexcept
         {
            return m_msg;
         }
   }
else
   {
      [[nodiscard]] intrusive_ptr_t< envelope_type >
      make_reference() noexcept
         {
            return { std::move(m_msg) };
         }
   }

   private :
      intrusive_ptr_t< envelope_type > m_msg;
   };

Как по мне, так отличия слишком уж разительны. И они не в пользу текущего C++ :(
(разобранный выше C++ный код в виде одной сплошной "портянки" можно увидеть здесь).


Кстати говоря, я не очень сильно слежу за тем, что происходит в области предложений по метапрограммированию и рефлексии для будущих версий С++. Но из того, что помню, складывается ощущение, что предлагавшиеся Саттером метаклассы не очень упростят вот эту конкретную задачу. Как я понимаю, посредством метаклассов можно будет написать генератор классов message_holder_t. Может быть такой генератор получится и несложным в написании, но вряд ли такой подход в данном конкретном случае окажется выразительнее и понятнее, чем в случае действительно продвинутого if constexpr.


Заключение


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


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


Тем не менее, сам факт того, что на С++ можно такое сотворить, меня лично радует. Огорчает количество труда и объем кода, который для этого потребуется. Но, надеюсь, что со временем объем этого кода и его сложность будет только сокращаться. В принципе, это видно уже сейчас. Ибо для C++98/03 я даже не взялся бы такой трюк проделывать, тогда как начиная с C++11 делать подобное становится все проще и проще.

Комментарии (60)


  1. picul
    23.04.2019 15:43

    Ну, мне не кажется что вариант в Gist'е и гипотетический вариант равносильны. В Gist'е все по максимуму разложено на стратегии, а глядя на гипотетический, создается впечатление, что нужно просто реализовать одну функцию отдельно для shared и unique стратегии, а от полученного отнаследовать все остальное. В результате получится чуть больше кода, но читать и поддерживать это будет легче, чем простыню constexpr if'ов.
    Что до constexpr if'а как такового — тут две стороны медали. С одной стороны, для несложных случаев это экономит нажатия на клавиатуру, с другой — как я уже сказал, думаю, удобнее поддерживать грамотное разложение на стратегии, чем кашу из условий.


    1. eao197 Автор
      23.04.2019 17:18

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


      Тогда как при использовании static if вы просто линейно читаете код. Да, если там будут запутанные условия, то придется поразбираться с тем, что включается, а что исключается из рассмотрения. Но, при наличии запутанных условий, код и раздельными классами-стратегиями окажется так же сложнее (по крайней мере метафункции-селекторы усложнятся).


      Еще один фактор — как задокументировать то, что в итоге получилось. При наличии static if-а инструмент вроде Doxygen-а может быть способен построить документацию по классу, в которой будет видно какие методы при каких условиях присутствуют. А вот в случае с наследованием сделать такое, имхо, может быть сильно сложнее.


      1. picul
        23.04.2019 18:24

        Стратегии существуют не для того что бы заменить static if, а для того, что бы абстрагировать конкретные реализации. Например, можно завернуть в стратегию функционал создания/копирования/перемещения указателя, и реализовать стратегию unique и стратегию shared. Классов много, но не нужно держать в голове конкретную реализацию.
        А о наследовании, кстати, вопрос — а зачем вообще все от всего пронаследовано? Если Вам нужен разный интерфейс в зависимости от Ownership — это решается частичной специализацией.


        1. eao197 Автор
          23.04.2019 23:17

          Если Вам нужен разный интерфейс в зависимости от Ownership — это решается частичной специализацией.

          Частичная специализация хорошо работает, когда селекция между специализациями делается по одному признаку. Когда же она происходит по нескольким признакам, то специализации начинают тяготеть к копипасте. Вот как в данном случае.


          1. picul
            24.04.2019 13:40

            Вот как раз в данном случае имеем два варианта класса — в зависимости от Actual_Ownership меняются copy конструктор/operator= и метод make_reference. Выносим вывод Actual_Ownership в отдельный класс, делаем две частичные специализации для shared и unique — все, holder готов, кода почти столько же, сколько в гипотетическом варианте, но зато более читабельно.


            1. eao197 Автор
              24.04.2019 13:46

              Читабельность понятие субъективное, поэтому позволю себе усомниться в том, что читабельность варианта с несколькими классами и частичной специализацией отдельных их частей будет выше, чем простой линейный код с парой static if-ов.

              Ну и, все так же остается вопрос о том, как все это документировать.


      1. Ryppka
        24.04.2019 08:38

        А если 'static' на '#' заменить?))))


        1. eao197 Автор
          24.04.2019 10:27

          Т.е. обойтись только препроцессором?


          1. Ryppka
            24.04.2019 11:45

            Ну, только препроцессором может не получится, там же compile time integer expression требуется, если мне склероз не изменяет.
            А если по сути, то и if constexpr, и static if могут использоваться для условной компиляции. Вам нужно как раз такое применение. У if constexpr ограничения применимости строже, что, на мой взгляд, лучше, но Вам это бьет по рукам.

            Если по существу, то если с помощью enable_if::value и т.д. можно получить compile time integer expression в контексте препроцессора, то вот Вам и static if. Но брателлос сомневаются…


            1. eao197 Автор
              24.04.2019 12:31

              Если по существу, то если с помощью enable_if::value и т.д. можно получить compile time integer expression в контексте препроцессора

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


  1. Ilias
    23.04.2019 17:57

    я дико извиняюсь за вопрос не по теме, но вот тут

    : a_{a}, b_{std::move(b)}, c_{c}

    фигурные скобки вместо круглых зачем? это какая-то новая мода, пихать их везде, где компилятор не ругается?


    1. eao197 Автор
      23.04.2019 18:23

      Мода. Началась с C++11.


  1. Livid
    23.04.2019 18:05

    Ожидал прочитать про SFINAE и увидеть старый-добрый std::enable_if. Не угадал. Вопрос к автору, особенно после прочтения


    Определить разные методы внутри класса в зависимости от какого-то условия мы не можем

    Автор умеет в SFINAE или кто?


    1. eao197 Автор
      23.04.2019 18:26

      Если нужно вводить методы пачками по 3-4-5 и более штук, то обкладывать каждый из них SFINAE… Ну то еще удовольствие.


      1. Livid
        25.04.2019 08:07

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


        Но сама процитированная выше фраза ставит в тупик — как так "не можем", это же неправда.


        1. eao197 Автор
          25.04.2019 08:14

          Так если есть серьезные препятствия (например, в виде резкого ухудшения читаемости кода), то это же невозможность и есть. Нет?

          Кроме того, если методы требуют для себя еще какие-то специфические данные, то вот дополнительные данные в класс мы же через SFINAE не засунем. Тогда как через условное наследование — без проблем.

          Т.е. получается, что условное наследование мощнее и читабельнее, чем SFINAE.


          1. Livid
            25.04.2019 08:42
            +1

            серьезные препятствия [...] это же невозможность и есть

            Ну извините, тут Вы хватили, это уже демагогия на пустом месте. Невозможность — это не когда "серьёзные препятствия", а когда возможности совсем нет, по крайней мере без серьёзного изменения исходных. Тем более что "читаемость кода" никак нельзя считать "серьёзным препятствием" — это, во-первых, категория чисто субъективная, а во-вторых, ей жертвуется постоянно по любому поводу.


            условное наследование мощнее и читабельнее

            Касательно "читабельнее" — тоже субъективно, об этом можно бесконечно и бестолково спорить. Мощнее да.


            Но в процитированной выше фразе не шло речи об определениях новых полей класса. Только методов.


            Вы как-то не к месту выкручиваетесь и демагогию разводите, мне кажется. Я может неудачно исходное сообщение сформулировал, но его надо в слегка шутливом тоне читать, а не в конфронтационном — так что смысла в глухую оборону уходить нет, задачи Вас как-то оскорбить или унизить у меня не было и нет.


            Вопрос тем не менее вполне серьёзный — в статье, на мой взгляд вот это "определить разные методы [...] от условия [...] не можем" выглядит мягко говоря сомнительно. Может быть, стоило упомянуть про SFINAE и объяснить чем он здесь не подходит и чем предлагаемый подход лучше, вместо этого категорического "не можем"?


            1. eao197 Автор
              25.04.2019 08:52

              > Невозможность — это не когда «серьёзные препятствия», а когда возможности совсем нет, по крайней мере без серьёзного изменения исходных.

              Может казаться, что я изворачиваюсь, но. Так уж получилось, что на C++ программирую довольно давно и время от времени сталкивался с ситуациями, когда компилятор просто падал на каком-то сложном коде. Т.е. код был формально правильным, успешно собирался другим компилятором, но вот нужный мне компилятор выпадал с ICE. И приходилось код переделывать, чтобы компилятор смог с ним справиться. В последний раз такое было в 2016-ом году, как раз там сложное сочетание шаблонов мы пытались задействовать.

              Так что для меня возможность — это когда ты пишешь код, который достаточно простой, понятный и наверняка не вызовет каких-то серьезных проблем. А когда этого нет, то это уже «невозможность».

              > Но в процитированной выше фразе не шло речи об определениях новых полей класса. Только методов.

              Здесь, видимо, плохо сформулированная фраза в тексте. К сожалению, не только код приходится писать в условиях горящих сроков, но и время на написание статей не резиновое… :(

              Тут вы правы, безусловно.

              > Может быть, стоило упомянуть про SFINAE и объяснить чем он здесь не подходит и чем предлагаемый подход лучше, вместо этого категорического «не можем»?

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

              PS. Я ваш комментарий не минусовал.


        1. Ryppka
          25.04.2019 09:07

          Не препирайтесь) И так и так получается так себе… )))


          1. eao197 Автор
            25.04.2019 09:09

            Ну, как оказалось, текст статьи можно было бы сделать лучше и более однозначно воспринимаемым. Так что замечания ув.тов.@Livid оказались полезны.


  1. Wyrd
    23.04.2019 21:04

    Мне вот почему-то кажется что это не один класс-контейнер должен быть а целый набор РАЗНЫХ классов… Потому что ведут они себя по разному, хранят данные разного характера и т.п.


    Если утка и самолёт оба летают это не делает их одной сущностью у которое «просто разные стратегии заправки и удержания себя в воздухе»


    Поправьте если я не прав..


    1. eao197 Автор
      23.04.2019 23:15

      Исторически был только один класс сообщений — наследники от message_t, поэтому конструкция:


      send<some_message>(dest, ...);

      была вполне себе однозначной и не требовала какого-либо разнообразия при обработке типа some_message.


      Но, со временем, потребовалось поддерживать варианты, когда some_message мог быть произвольным типом. Поэтому внутри SObjectizer-а пришлось различать ситуации, когда сообщение внутри представляется как intrusive_ptr_t<Msg>, а так же когда сообщение представляется как intrusive_ptr_t<user_type_message_t<Msg>>.


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


      И возник вопрос, как сделать это так, чтобы пользователю не нужно было думать, хранить ли intrusive_ptr_t<some_message> или intrusive_ptr_t<user_type_message_t<some_message>>. В качестве решения был придуман класс message_holder_t<Msg>, который брал бы на себя все эти детали.


      Изначально задумывалось, что message_holder_t<Msg> будет вести себя как shared_ptr для иммутабельных сообщений и как unique_ptr для мутабельных. Но даже на штатных примерах SObjectizer-а выяснилось, что такого простого разделения недостаточно. Как минимум нужно иметь логику shared_ptr для мутабельных сообщений. Отсюда и добавление параметра Ownership в шаблон message_holder_t.


      Можно ли было покрыть все это разными типами?


      Наверное да.


      Но вот было бы это удобно для пользователя фреймворка?..


      Вот в этом я лично сильно сомневаюсь. Особенно с учетом того, что агенты могут быть шаблонами. И внутри шаблона не так уж просто разбираться с тем, как хранить сообщение. Тогда как message_holder_t берет на себя все эти проблемы.


      1. Wyrd
        24.04.2019 01:34

        Похоже дело вкуса — я лично за вариант с разными типами, может оно чуть менее удобно зато намного более прозрачно и понятно...


        1. eao197 Автор
          24.04.2019 07:46

          Так вы запросто можете получить те типы, которые вам нужны:


          template<typename M>
          using shared_message_holder_t = message_holder_t<T, message_ownership_t::shared>;
          
          template<typename M>
          using unique_message_holder_t = message_holder_t<T, message_ownership_t::unique>;


          1. Wyrd
            24.04.2019 19:11

            Это понятно, что так можно делать. Я попробую перефразировать — мне кажется типы принципиально разные, поэтому ничего общего на уровне «интерфейса» они иметь не должны. Сам факт того что внешний (для вашей библиотеки) код пытается относится к этим, по сути, разным типам как к чему-то единому может указывать на проблемы дизайна и нестыковки в доменной модели (возможно я ошибаюсь — глубоко не вникал — но субъективное ощущение именно такое).


            ...


            Я несколько лет назад ушёл из C++ в C# и некоторое время мне было тяжело привыкнуть к «ограничениям» языка — множественное наследование — нельзя, шаблонная магия — нельзя, частичная специализация — нельзя и т.п. Потом, со временем, я понял что эти «ограничения» вынуждают меня писать более чистый код — в 99.9% случаев моя (провальная) попытка использовать что-то из запрещённых приёмов приводила меня к более простому и понятному коду в итоге.


            ...


            Конкретно в случае акторов и сообщений я бы попытался сделать сообщения простыми DTO объектами которые создаются в момент отправки и уничтожаются после доставки сообщения немедленно. Если кому-то нужно «сохранить» сообщение — пусть копирует его данные к себе. Таким образом формат сообщений не будет влиять на внутренности реализации потребителей — метод который вытаскивает из сообщения полезные данные и сохраняет (в другой тип!) будет в одном месте и его легко подправлять в случае чего. Ну и в целом по код станет понятнее и без шаблонной магии. Из минусов это может быть немного медленнее (надо мерять) и возможно чуть более размашистей по количеству строчек кода. Но в целом ИМХО будет проще.


            p.s. Вышенаписанное здесь на правах альтернативного мнения и концептуальных идей. Я не вникал в библиотеку глубоко и могу в чём-то заблуждаться, но идея должна быть понятна: делайте проще.


            p.p.s. Сам этим страдаю :(


            1. Wyrd
              24.04.2019 19:16

              В догонку: это иллюзия что теперь все хорошо клиенты могут использовать одинаково message_holder — его семантика скрыта в типе, концептуально, для разных типов, код будет разным даже если написать auto message = ...; выглядеть-то он будет одинаково, а вот поведение разное и его все равно нужно держать в голове.


              1. Wyrd
                24.04.2019 19:17

                Немного путано получилось, но надеюсь суть Вы уловили. Пишу с телефона — сори.


            1. eao197 Автор
              24.04.2019 19:19

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

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


              template<typename Request_Msg, typename Ack_Msg, typename Nack_Msg>
              class my_actor final : public so_5::agent_t {...};

              Если в таком шаблонном акторе нужно сохранить сообщение, скажем, типа Request_Msg, то гораздо проще это делать когда есть всего один message_holder_t, чем разные типы holder-ов.


              Конкретно в случае акторов и сообщений я бы попытался сделать сообщения простыми DTO объектами которые создаются в момент отправки и уничтожаются после доставки сообщения немедленно.

              Такое решение нужно было принимать лет 9 назад. А так как сообщения не DTO объекты, и летают не только в режиме 1:1, но и 1:M, то теперь приходится поддерживать то, что уже есть.


              1. Wyrd
                24.04.2019 19:30

                Ну мы кажется поняли друг друга :)


                • см. мой второй комментарий — то что теперь можно писать шаблонные акторы принимающие разные типы и как-то их сохраняющие ИМХО не есть айс. Потому что семантика этих типов разная а в коде это никак не видно и приходится держать в голове.

                По поводу 9 лет назад — может новую мажорную версию выпустить? :) 1: М имхо не есть проблема — главное явно уничтожить сообщение после того как все М отработают. Тогда всем придётся сообщение (точнее его данные) копировать если надо. Из бесплатных бонусов — невозможность неявного влияния одного актора на другой — то что вы сейчас закрываете иммутабельностью. Медленней чуток, да… но не факт что это проблема — сервера вон джейсоны гоняют и парсят направо-налево и ничего с ними страшного не происходит.


                1. eao197 Автор
                  24.04.2019 20:18

                  то что теперь можно писать шаблонные акторы принимающие разные типы и как-то их сохраняющие ИМХО не есть айс.

                  Напротив, возможность делать шаблонных агентов — это большущий плюс SObjectizer-5. Кому это не нужно, тот может эту функциональность и не использовать. Кому потребуется, тот только спасибо скажет.


                  По поводу 9 лет назад — может новую мажорную версию выпустить?

                  Так версия 5.6, которая сейчас в фазе стабилизации находится, и есть мажорная. Почти 4.5 года мы развивали версию 5.5 с самой серьезной оглядкой на совместимость. В этом году нарушаем совместимость, очищаемся от старых костылей и выкатываем 5.6.


                  Но слишком уж кардинально менять принципы работы — это не вариант.


  1. kovserg
    23.04.2019 21:33

    При решении какой реальной задачи возникла эта абстрактная задача?
    В C++ очень любят создавать проблему из ничего и раздувать её, а потом героически преодолевать всё это в общем виде, при этом если чего-то не хватает добавлять это в стандарт и решать эту же задачу еще раз но «лучше».
    При этом связь с реальной физической проблемой теряется, но такие мелочи никому не интересны.


    1. eao197 Автор
      23.04.2019 23:38

      При решении какой реальной задачи возникла эта абстрактная задача?

      Во-первых, задача не абстрактная.


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


      Если вам всерьез интересно, зачем все это потребовалось, то, пожалуйста, вот обоснование:


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


      1. Актор создается в некий момент T, когда есть все значения для создания экземпляра сообщения M. Агент должен сохранить у себя все эти значения и дождаться команды "ты можешь начать работать". Когда такая команда приходит, агент должен отослать куда-то сообщение M. Но команда может и не прийти. Возникает вопрос: как сохранить параметры для сообщения M? Можно их сохранить как атрибуты агента, а потом из атрибутов создать сообщение M. А можно сразу создать сообщение M и хранить его до момента времени Ч. Соответственно, message_holder_t решает задачу хранения M.
      2. Некий агент получает сообщение M и должен сохранить его у себя до какого-то момента. Например, этот агент является балансировщиком нагрузки. Он получает сообщения, складирует их у себя и раздает по мере возможности агентам-воркерам. Возникает вопрос: как складировать эти сообщения M? Соответственно, message_holder_t решает эту проблему.
      3. Бывает необходимость организовать обмен преаллоцированными сообщениями. Т.е. сообщения создаются при старте программы, только при старте выделяется память под сообщения, а потом используются только преаллоцированные сообщения. Возникает вопрос, как хранить эти преаллоцированные сообщения? Шаблон message_holder_t решает эту проблему.

      Так что задача для пользователей конкретного фреймворка отнюдь не абстрактная.


      1. kovserg
        24.04.2019 20:31

        То есть решается проблема, созданная фреймворком инструментом написанным специально для решения «неких» проблем сферических коней в адиабатическом поле с голономными связями?


        1. eao197 Автор
          24.04.2019 20:40

          И почему мне кажется, что вы сюда не для конструктивного общения пришли?


          Мы сделали и развиваем инструмент, который упрощает многопоточное программирование. Кому-то он помогает. Кто-то в нем не нуждается.


          Но если им воспользоваться, то можно встретиться с одной из перечисленных выше ситуаций. Для которых описанный в статье класс и был сделан. Считаете это "проблемами сферических коней в адиабатическом поле с голономными связями"? Да нет проблем.


          Но, если не лень, поинтересуйтесь на досуге, почему разработчики и пользователи таких инструментов, как Akka, Orleans, Actix, CAF, QP/C, Celluloid и пр. не рассматривают это все как "проблемы сферических коней в адиабатическом поле с голономными связями".


          1. kovserg
            24.04.2019 22:33

            У вас наверное просто глаз замылен. После прочтения постановки задачи и ограничений на решение сразу видно что это именно борьба с мельницами которые сами же построили. Когда инструмент для упрощения создаёт больше проблем чем решает это повод задуматься.
            Почему-то эрланге без static if обходятся.


            1. eao197 Автор
              24.04.2019 22:44

              > Почему-то эрланге без static if обходятся.

              Там еще и без классов обходятся. И узкие места на C пишут. А потом вообще берут, и Elixir на базе Erlang-овской VM делают. Видимо потому, что Erlang настолько хорош.


              1. kovserg
                25.04.2019 00:42

                Так для этого языки склейки типа python, javascript, lua… и нужны что бы вынести языки типа C и C++ под капот и решать реальные задачи отделив людей от аппаратной части и надуманных проблем борьбы с дополнительной сложностью, возникшей в процессе эволюции инструмента призванного бороться со сложностью первоначальной задачи.
                Причем тут erlang-vm для узких мест при необходимости и специальное железо делают.


                1. eao197 Автор
                  25.04.2019 08:28

                  Это у вас нужно спросить, причем тут Erlang. Вы же сказали:


                  Почему-то эрланге без static if обходятся.

                  В Erlang-е много без чего обходятся. И, что характерно, обходятся далеко не всегда. Поэтому, в частности, делают Elixir, чтобы получить больше возможностей в рамках экосистемы Erlang-а (ибо выразительной мощи Erlang-у для каких-то проектов явно не хватает).


                  Что означает, что не следует оглядываться на Erlang, когда речь идет про разработку на C++.


                  Далее, если я правильно понимаю вашу точку зрения, нет смысла писать все на C++. Мол, нужно на C++ написать только отдельные куски, для которых критична производительность, а все остальное — на Python, Ruby, JS и пр.


                  Это ваша точка зрения. Может быть даже она обоснована вашим опытом. Мне как-то фиолетово, я не собираюсь обсуждать ни эту точки зрения, ни ее адекватность, ни ее применимость в тех или иных условиях.


                  Есть простой факт, который, возможно, вам непонятен или вызывает жжение в каких-то местах вашего организма, но тем не менее, люди пишут софт на C++. Иногда целиком, иногда какие-то изрядные куски софта. Но C++ широко используется для решения сложных и объемный (в смысле объема результирующего кода) задач.


                  А раз так, то C++никам нужны различные инструменты, которые облегчают им их работу. Мы один из таких инструментов делаем. И, по мере сил рассказываем о том, что и как мы делаем. Что, как показывает практика, кому-то даже интересно. Данная статья — один из таких рассказиков.


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


  1. Cfyz
    23.04.2019 22:47

    На мой взгляд в статье недостаточно проиллюстрированы озвученные требования к типу-обертке. Практически по всем сразу же возникает мысль «зачем так сложно», хотя допускаю есть причина зачем. Но это затрудняет восприятие решения =|.

    Если сообщение неизменяемое (а по умолчанию оно неизменяемое), то методы-getter-ы должны возвращать константный указатель на сообщение. А если изменяемое, то getter-ы должны возвращать не константный указатель.
    Если значение иммутабельно как тип, то какой указатель на него не верни, будет значение, которое не получится изменить. Если экземпляр иммутабельного типа можно нечаянно изменить, то это как минимум странно. Если тип на самом деле не иммутабелен, а просто хочется некоторые экземпляры сделать иммутабельными, то наверное это надо делать не силами контейнера?

    Когда-то он должен вести себя как std::shared_ptr, т.е. можно иметь несколько message_holder-ов, ссылающихся на один и тот же экземпляр сообщения. А когда-то он должен вести себя как std::unique_ptr, т.е. только один экземпляр message_holder-а может ссылаться на экземпляр сообщения.
    Эти стратегии владения настолько разные, что лучше и типа иметь два разных. Иначе получается странный тип, поведение которого прояснится только после (не)успешной компиляции. Ну а чтобы пользователь не расслаблялся в случае успешной компиляции,
    А метод make_reference должен изымать указатель у объекта message_holder_t: после вызова make_reference исходный message_holder_t должен остаться пустым.
    перенесем один из сюрпризов на время выполнения =). Это что угодно, но только не «make a reference», не надо так =/.

    Кстати, выключать конструкторы-операторы можно еще и таким образом, без применения дополнительного наследования:
    template<typename T> struct Foo {
        Foo() { }
        Foo(const Foo&) {
            static_assert(std::is_integral<T>::value, "");
        }
    };
    
    void test() {
        Foo<int> f1;
        Foo<int> f2{f1}; // Ok
        Foo<std::string> f3;
        Foo<std::string> f4{f3}; // Fail
    }
    

    Поправьте меня, если я неправ, но вроде бы по стандарту методы шаблона инстанцируются только если они используются. Ну, как минимум все крупные компиляторы ведут себя желаемым образом =).


    1. eao197 Автор
      23.04.2019 23:47

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

      Старался описать максимально подробно и без лишнего погружения в детали. Не знаю, как описать еще проще. Если все равно остались вопросы, то задавайте. Вероятно, проще будет ответить на них.


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

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


      Эти стратегии владения настолько разные, что лучше и типа иметь два разных.

      Да, разные. Но причины иметь единственный шаблон message_holder_t описаны здесь: https://habr.com/ru/post/449122/#comment_20067302


      Это что угодно, но только не «make a reference», не надо так =/.

      Это дань историческим традициям.


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

      В случае, если два метода следует исключить из рассмотрения компилятора, условия в static_assert пришлось бы продублировать. Если таких методов три — то нужно трехкратное дублирование и т.д.


  1. Ryppka
    24.04.2019 08:53

    Статья хорошая и описывает трудности, которые реально нет-нет да и да. Предложенное решение вроде работает, но, на мой вкус, не производит впечатления простоты. Что касается D и static if как вариант условной компиляции на стероидах, то, опять же, с моей скромной точки зрения, это может работать в простых случаях, но при усложнении заведет в тупик, так же как enable_if/SFINAE, if constexpr и т.д. Все эти решения, ИМХО, плохо масштабируются при росте сложности и плохо применимы за достаточно узкими пределами.
    Лучшим решением, на мой взгляд, тут была бы наследование и dynamic_cast. При всех минусах этого подхода.


    1. eao197 Автор
      24.04.2019 10:26

      > Предложенное решение вроде работает, но, на мой вкус, не производит впечатления простоты.

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


      1. Ryppka
        24.04.2019 14:22
        +1

        Я, видимо, неточно выразился. Нормальное библиотечное решение. Еще бы закрыть практическое использование макросом, чтобы меньше печатать — и в продакшен). Под сложностью я понимаю следующее:

        1. Могу ли я понять Вашу статью. Громоздко — да. Но понять не сложно.
        2. Понял бы я «шоетазанах», столкнувшись с таким кодом без комментариев? Ну, не знаю, если бы с использованием не было бы проблем, то громоздкость меня бы отпугнула.
        3. Смог бы я до прочтения Вашей статьи сам такое написать? Не факт, сильно сомневаюсь.

        Могу только согласиться с тем, что C++ шаблонное метапрограммирование несовершенно и слишком усложняется при усложнении задачи. Не уверен, но именно для решения подобных Вашим проблем Саттер и предлагает метаклассы.
        Что не понравилось — Вы хотели бы изменить сигнатуры типов с помощью условной компиляции, чтобы меньше печатать и легче читать. Понятно, но я считаю применение условной компиляции для таких задач принципиально ошибочным.


        1. eao197 Автор
          24.04.2019 14:34

          Что не понравилось — Вы хотели бы изменить сигнатуры типов с помощью условной компиляции, чтобы меньше печатать и легче читать.

          Тут нужно дать некоторое пояснение. Когда я только начал делать этот самый message_holder_t, то предполагалось, что различия в составе и сигнатурах методов, в зависимости от параметров шаблона, будут более существенными. Но по мере реализации стало выясняться, что этого не произойдет. Что разница будет на уровне сигнатур методов. Более того, уже по ходу написания статьи пришло понимание того, как обойтись одним msg_accessor_t, а не двумя, как в том варианте, который и пошел в работу.


          Так что, когда статья была завершена, выяснилось, что различия в вариантах кода не столь существенны и наглядны. Но и отказаться от публикации не смог :)


          Теперь пару слов про субъективность восприятия.


          ИМХО, это нормально, когда мне нравится один подход, вам другой, кому-то третий. Ведь это то, с чем мы постоянно имеем дело в жизни: дай трем разным людям, с разным опытом и взглядом на программирование одну и ту же задачу, они решат ее тремя разными способами. Причем не факт, что будут положительно отзываться о чужих решениях ;)


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


          Это сожаление и явилось причиной, толкнувшей на написание статьи.


  1. oktonion
    24.04.2019 18:27

    Я правильно понимаю что главный посыл статьи: "специализация шаблонного класса муторно, enable_if для включения/выключения функций класса не читабельно (?), условное наследование реализаций как у нас — сложно в поддержке"? То есть вам даны три инструмента (кстати на c++98/03 не сильно то отличаться реализация будет), но все равно нужен четвертый "как в D"?


    1. eao197 Автор
      24.04.2019 18:33

      По мне, сам факт того, что даны уже три инструмента для решения одной и той же задачи — это маркер того, что инструменты как-то не очень. Ну а там, где три, почему бы и не четыре?

      Тем более, что не факт, что наличие `static if` не сделало бы ненужным два из этих трех инструментов.

      > enable_if для включения/выключения функций класса не читабельно (?)

      Мало того, что нечитабельно, так еще и ограничено в возможностях. Методы добавлять/изымать можно, а вот как быть с полями класса?


      1. Ryppka
        25.04.2019 09:16

        Еще раз скажу: вы хотите условной компиляции, только не на этапе препроцессирования, а на этапе инстанциации шаблонов. Как практик я Вас понимаю. Но в статье это выглядит, как пропаганда «срезания углов» в ущерб системе типов. Это всего лишь мое мнение, no obligations.
        Но Ваша статья заставила меня еще раз присмотреться к идее метаклассов. Может быть, это и не так плохо, как мне казалось…


        1. eao197 Автор
          25.04.2019 09:28

          > Еще раз скажу: вы хотите условной компиляции, только не на этапе препроцессирования, а на этапе инстанциации шаблонов.

          У меня даже есть подозрение, что `static if` мог бы быть полезен и при определении обычных классов, а только не шаблонов.

          Т.е. суть в том, что в рамках чистого C отдельно препроцессор и отдельно язык вполне себе нормально дополняли друг друга (ибо оба были весьма убоги и ограничены). Но уже в C++ тот факт, что препроцессор вообще никак не связан с языком, уже гораздо печальнее. И подход к «условной компиляции», который сделали в D (а это не только static if, но еще и version, и static_foreach) выглядит гораздо лучше, чем то, что мы имеем в C++. А с учетом того, что в C++20 обещают завести модули и препроцессор там перейдет в категорию инструмента, который не очень-то и рекомендуется использовать, D-шные конструкции начинают выглядеть еще привлекательнее. ИМХО.


      1. oktonion
        25.04.2019 21:08

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

        Странно полагаться на то что кто-то будет разбираться как там вы свои классы понаписали с условной компиляцией, будь там хоть эти три решения что уже есть в языке, хоть 'static if'. Программист-пользователь вашей библиотеки будет в первую очередь смотреть примеры, во вторую — документацию. А вот если ему придется лезть в чужой код библиотеки, да еще и шаблонный, да еще и с условной компиляцией, да еще и с такими условиями что классы ведут себя практически принципиально по-разному, то в большинстве случаев программист-пользователь выберет другое решение для своей задачи.

        Не поймите неправильно, я сам радуюсь шаблонам и возможностям, которые они открывают. Но суть в том что 'static if' может помочь только вам, как разработчику сложной библиотеки, для более быстрого написания кода. И это «более быстро» измерить я не берусь, хотя кажется мне что игра не стоит свеч, а именно реализация такого в компиляторе не стоит такой экономии.


        1. eao197 Автор
          25.04.2019 21:44

          Я на это смотрю так: SFINAE выглядит как побочный продукт развития шаблонов. Как то самое метапрограммирование на тех самых шаблонах: о нем не думали изначально, как бы само получилось. И как побочный продукт SFINAE быстро показывает свои ограничения. Т.е. когда есть одна функция, для которой нужно сделать перегрузки для разных ситуаций, SFINAE еще нормален. Да и то… Вот, скажем, фрагмент, который был написан прямо сегодня:


          template<
              typename Request,
              typename Reply,
              typename Target,
              typename Duration,
              typename... Args >
          [[nodiscard]]
          std::enable_if_t<
              std::is_default_constructible_v<
                  typename request_reply_t<Request, Reply>::reply_t>,
              typename request_reply_t<Request, Reply>::reply_t>
          request_value(Target && target, Duration duration, Args && ...args )
              {
                  using requester_type = request_reply_t<Request, Reply>;
                  ...
              }
          
          template<
              typename Request,
              typename Reply,
              typename Target,
              typename Duration,
              typename... Args >
          [[nodiscard]]
          std::enable_if_t<
              !std::is_default_constructible_v<
                  typename request_reply_t<Request, Reply>::reply_t>,
              typename request_reply_t<Request, Reply>::reply_t >
          request_value(
              Target && target,
              Duration duration,
              Args && ...args )
              {...}

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


          Но вот когда нужно сразу несколько функций/методов разрешить, то выписывать все эти простыни условий в enable_if-ах… Даже при всем том, что можно их подсократить введя какие-то промежуточные константы и тайпдефы. Все равно получается коряво.


          При этом сейчас, в C++17, можно сделать и по другому. Вместо двух функций с разными предикатами в enable_if можно сделать всего одну, у которой внутри будет if constexpr:


          template<
              typename Request,
              typename Reply,
              typename Target,
              typename Duration,
              typename... Args >
          [[nodiscard]]
          auto
          request_value(Target && target, Duration duration, Args && ...args )
              {
                  using requester_type = request_reply_t<Request, Reply>;
                  if constexpr(std::is_default_constructible_v<
                      typename requester_type::reply_t>)
                  {
                      ... // Простыня текста.
                  }
                  else
                  {
                      ... // Простыня текста.
                  }
              }

          Т.е. уже сейчас SFINAE может быть заменен на if constexpr. А если бы if constexpr был еще мощнее, то во многих случаях от многословных и не всегда понятных SFINAE можно было бы вообще отказаться.


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

          Так в принципе уже сейчас если заглянуть в навороченную шаблонную библиотеку, в которой еще нет if constexpr вообще, есть хороший шанс утонуть в деталях реализации. И далеко не всякий C++ разработчик способен разобраться в чужом шаблонном коде, к сожалению.


          Я нахожусь при мнении, что мощный аналог static if в C++ был бы полезен, чем SFINAE. И, имхо, разработчики на D подтвердят, то это удобная штука.


          1. oktonion
            25.04.2019 22:11
            +1

            Так вот я с вами согласен и разделяю всю боль шаблонного программирования. Но диалог как-то странно у нас с вами строится:
            — Имеющиеся подходы к условной компиляции не удобны. Клево было бы иметь 'static if'.
            — Согласен, это клевая штука, но очень далеко не приоритетная и нужна по сути разработчику библиотеки, а не ее пользователю. Да и есть уже три подхода для решения таких задач в языке прямо сейчас.
            — Но вот же какой некрасивый код у разработчика библиотеки с такими подходами, а с 'static if' было бы не так страшно!
            — Согласен, код не очень читаемый для неподготовленного пользователя библиотеки, хотя задачу свою выполняет. Но его никто кроме разработчика библиотеки и смотреть не должен, все должно быть интуитивно, так же в примерах, и напоследок в документации.
            — Жаль что не каждый разработчик способен разобраться в шаблонном коде чужой библиотеки.
            — Так ведь это… не его задача — разбираться в чужом коде…
            — Мощный аналог 'static if' в C++ был бы полезен.
            — Так я же согласен… но не приоритетно… да и есть уже три подхода…
            — В D круто сделано, там разработчикам проще от этого.
            — Так согласен… Так крутая фишка, да… но ведь… ай ладно.

            Утрирую конечно, но как то так. =)


            1. eao197 Автор
              25.04.2019 22:15

              Да, диалог странный и вы хорошо его резюмировали :)

              Я вот не согласен с тем, что разработчику не нужно разбираться в чужом коде. Хорошо, когда не нужно это делать. Но когда приходится, то хорошо бы делать это без особых затрат. А если в чужой код залезть тяжело, то можно нарваться на «закон текущих абстракций».


              1. oktonion
                25.04.2019 22:33

                Хорошо бы, да обычно «без затрат» — это взять и задать вопрос разработчику кода или вообще взять другое решение, благо их хватает обычно.

                Потому что «текущие абстракции» если это, к примеру, про просадки в производительности в чужом коде — решение не разобраться что же там понаписали то внутри, решение это пользоваться кодом по документации другим способом (очевидно более производительным) и как максимум завести issue в репозитории разработчика, либо перейти на другую библиотеку (другой код) и забыть об этой «тормознутой» библиотеке на время.

                А все от того что в большинстве случаев нет времени копаться в чужих сложных решениях. Они, эти решения, абстракции, и берутся для того чтобы данную сложность сократить за счет усилий разработчика библиотеки (чужого кода). А вот если из примеров использования, здравого смысла разработчика-пользователя библиотеки и документации на нее не понятно, где «абстракция течет», то пользоваться таким чужим решением очень опасно и по-хорошему бежать надо от такого кода, а не в недрах его копаться.

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


                1. eao197 Автор
                  26.04.2019 07:46

                  Просто немного дополню.


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


                  Ну и бывает так, что библиотека настолько критична, что отказаться от нее крайне сложно. Например, если вначале разработки заложились на Asio, то по ходу дела сменить ее на libuv или ACE будет затруднительно, уж слишком по разному нужно писать код. Тоже самое можно сказать и о нашем SObjectizer-е и его прямых конкурентах в мире C++: CAF и QP/C++. Если уж конкретная библиотека для работы с акторами была выбрана и было написано несколько десятков тысяч строк кода с их использованием, то взять и заменить CAF на SObjectizer или SObjectizer на QP/C++ будет ну очень больно.


  1. 0xd34df00d
    25.04.2019 23:11

    1. Нужно писать пропозал на const-operator, default-operator и delete-operator на манер noexcept-operator (чтобы можно было делать Foo() = default(someCompileTimeCheck)). Вот это бы было в стиле плюсов!
      Потом можно на override и на virtual, ну, так, для полноты. И на && ещё, auto getFoo() &&(check) — хорошо, ящетаю.
    2. А зачем вы явно пишете конструкторы структурам типа user_message? Я как пользователь сильно расстроюсь, ведь этим вы мне запретили использовать самую полезную фичу из C++20 — designated initializers, которые, впрочем, уже давно поддерживаются gcc и clang.


    1. eao197 Автор
      26.04.2019 07:37

      А зачем вы явно пишете конструкторы структурам типа user_message?

      Здесь user_message — это прямая копипаста с so5_message, из которой было удалено ненужное наследование, все остальное осталось.


      Я как пользователь сильно расстроюсь, ведь этим вы мне запретили использовать самую полезную фичу из C++20 — designated initializers, которые, впрочем, уже давно поддерживаются gcc и clang.

      Для нас и VC++ является компилятором, который в обязательном порядке нужно поддерживать. А там, в VS2017 еще не было вроде designated initializers. VS2019 пока еще не пробовал.


      Кроме того, в случае с user_type_message пользователю не приходится создавать экземпляры вручную. Это делается автоматически либо через send, либо описанный message_holder_t, внутри которых вызывается конструктор пользовательского сообщения, в который аргументы передаются через variadic templates.


  1. Centimo
    26.04.2019 01:32

    Не хочу показаться снобом, но где магия? Автор решил простую задачу стандартными средствами.
    Кроме того, решение автора (хоть я и кое-что поправил бы) выглядит гораздо лучше чем портянка кода, где в объявлении класса тебе приходится разбираться со всякими if'ами.


    1. eao197 Автор
      26.04.2019 07:42

      Не хочу показаться снобом, но где магия?

      Так в этой статье вроде бы магия и не обещалась.


      хоть я и кое-что поправил бы

      Что, если не секрет?


      выглядит гораздо лучше чем портянка кода, где в объявлении класса тебе приходится разбираться со всякими if'ами.

      Фокус в том, что когда стало понятно, что нужен этот самый message_holder_t, то его черновой вариант на static if-ах был набросан где-то минут за 15. Потом в течении нескольких часов из варианта на static if-ах создавался вариант на базовых типах и метафункциях. И этот вариант затем в течении двух последующих дней видоизменялся и упрощался.


      15 минут и пара-тройка часов.


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


      1. Centimo
        27.04.2019 16:23

        Так в этой статье вроде бы магия и не обещалась.

        Ну, упоминание шаблонной магии есть.

        Что, если не секрет?

        Я бы все наследуемые классы, которые получаются после вычисления мета-функций, вынес бы в аргументы шаблона для более цельного восприятия. Ну и сахарку бы насыпал синтаксического, чтобы не расползалось.

        Понятно, что главная проблема в том, что я уже просто старый и тупой

        Скорее в отсутствии опыта. Если бы Вы перед решением этой задачи пару недель ковырялись в метапрограммировании, ответ пришёл бы быстрее и проще.
        А то, что Вы хотите проще, быстрее и приседаний поменьше — вполне логично и ожидаемо, только вот на мой взгляд это неправильный выбор. Это как хотеть наклепать одну большую глобальную функцию, вместо того, чтобы разбивать задачу на более мелкие и подбирать удачные структуры данных.


        1. eao197 Автор
          27.04.2019 17:49

          Ну, упоминание шаблонной магии есть.

          Как и слова о том, что углубляться в уже существующую магию не будем.


          Я бы все наследуемые классы, которые получаются после вычисления мета-функций, вынес бы в аргументы шаблона для более цельного восприятия.

          Тут не понятно. Каждая метафункция получает столько аргументов, сколько нужно, не больше, не меньше. И возвращает всего один тип. Мне сложно представить, куда и затем этот тип еще нужно передавать.


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

          Тут почему-то приходят мысли на счет стокгольмского синдрома: если привыкнуть одевать штаны через голову, то данная задача становится привычной. Нужно ли привыкать ;)