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