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

С сожалением об отсутствии в C++ полноценного static if или…

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

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


Когда-то, уже довольно давно, язык 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 делать подобное становится все проще и проще.

Теги:
Хабы:
Всего голосов 31: ↑29 и ↓2+27
Комментарии90

Публикации

Истории

Работа

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

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

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань