... ¿cómo llenar una clase de plantilla con diferentes contenidos dependiendo de los valores de los parámetros de la plantilla?
Érase una vez, durante bastante tiempo, el lenguaje D se comenzó a hacer como "el C ++ correcto", teniendo en cuenta la experiencia adquirida en C ++. Con el tiempo, D se ha convertido en un lenguaje no menos complejo y más expresivo que C ++. Y ya C ++ comenzó a espiar a D. Por ejemplo, apareció en C ++ 17 if constexpr
, en mi opinión, es un préstamo directo de D, cuyo prototipo era D-shny static si .
Desafortunadamente, if constexpr
en C ++ no tiene la misma potencia que static if
en D. Hay razones para esto , pero todavía hay casos en los que solo puede lamentar que if constexpr
en C ++ no le permite controlar el contenido de C + + clase Me gustaría hablar sobre uno de estos casos.
Hablaremos sobre cómo hacer una clase de plantilla, cuyo contenido (es decir, la composición de los métodos y la lógica de algunos de los métodos) cambiaría dependiendo de qué parámetros se pasaron a esta clase de plantilla. Se toma un ejemplo de la vida real, de la experiencia de desarrollar una nueva versión de SObjectizer .
La tarea a resolver
Es necesario crear una versión inteligente del "puntero inteligente" para almacenar objetos de mensaje. Para que puedas escribir algo como:
message_holder_t<my_message> msg{ new my_message{...} }; send(target, msg); send(another_target, msg);
El truco de esta clase message_holder_t
es que hay tres factores importantes a considerar.
¿De qué tipo de mensaje se hereda?
Los tipos de mensajes que parametrizan message_holder_t
se dividen en dos grupos. El primer grupo son los mensajes que heredan del tipo base especial message_t
. Por ejemplo:
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} {} };
En este caso, message_holder_t dentro de sí mismo debe contener solo un puntero a un objeto de este tipo. Se debe devolver el mismo puntero en los métodos getter. Es decir, para el caso del heredero de message_t
debería haber algo como:
template<typename M> class message_holder_t { intrusive_ptr_t<M> m_msg; public: ... const M * get() const noexcept { return m_msg.get(); } };
El segundo grupo son los mensajes de tipos de usuarios arbitrarios que no se heredan de message_t
. Por ejemplo:
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} {} };
Las instancias de este tipo en SObjectizer no se envían solas, sino que se incluyen en un contenedor especial, user_type_message_t<M>
, que ya se hereda de message_t
. Por lo tanto, para tales tipos, message_holder_t
debe contener un puntero a user_type_message_t<M>
dentro de él, y los métodos getter deben devolver un puntero a 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); } };
Inmunidad o mutabilidad de los mensajes.
El segundo factor es la división de mensajes en inmutables y mutables. Si el mensaje es inmutable (y por defecto es inmutable), los métodos getter deben devolver un puntero constante al mensaje. Y si es mutable, los captadores deben devolver un puntero no constante. Es decir debería ser algo como:
message_holder_t<so5_message> msg1{...};
shared_ptr vs unique_ptr
El tercer factor es la lógica del comportamiento de message_holder_t
como puntero inteligente. Una vez que debería comportarse como std::shared_ptr
, es decir Puede tener múltiples message_holders que se refieran a la misma instancia de mensaje. Y una vez que debería comportarse como std::unique_ptr
, es decir solo una instancia de message_holder puede hacer referencia a una instancia de mensaje.
Por defecto, el comportamiento de message_holder_t
debería depender de la mutabilidad / inmutabilidad del mensaje. Es decir con mensajes inmutables, message_holder_t
debería comportarse como std::shared_ptr
, y con std::unique_ptr
mutables como std::unique_ptr
:
message_holder_t<so5_message> msg1{...}; message_holder_t<so5_message> msg2 = msg;
Pero la vida es algo complicado, por lo que también debe poder configurar manualmente el comportamiento message_holder_t
. Para que pueda hacer message_holder para un mensaje inmutable que se comporta como unique_ptr. Y para que pueda hacer message_holder para un mensaje mutable que se comporte como 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;
En consecuencia, cuando message_holder_t
funciona como shared_ptr, debe tener el conjunto habitual de constructores y operadores de asignación: tanto copiar como mover. Además, debe haber un método constante make_reference
, que devuelve una copia del puntero almacenado dentro de message_holder_t
.
Pero cuando message_holder_t
funciona como unique_ptr, entonces se debe prohibir el constructor y el operador de copia. Y el método make_reference
debería eliminar el puntero del objeto message_holder_t
: después de llamar a make_reference
message_holder_t
original debería permanecer vacío.
Por lo tanto, debe crear una clase de plantilla:
template< typename M, message_ownership_t Ownership = message_ownership_t::autodetected> class message_holder_t {...};
cual:
intrusive_ptr_t<M>
o intrusive_ptr<user_type_message_t<M>>
deben almacenarse dentro, dependiendo de si M se hereda de message_t
;- los métodos getter deben devolver
const M*
o M*
dependiendo de la mutabilidad / inmutabilidad del mensaje; - debe haber un conjunto completo de constructores y operadores de copia / movimiento, o solo un constructor y operador de movimiento;
- El método
make_reference()
debería devolver una copia del intrusive_ptr almacenado, o debería tomar el valor de intrusive_ptr y dejar el message_holder_t
original vacío. En el primer caso, make_reference()
debe ser constante, en el segundo método no constante.
Los dos últimos elementos de la lista están determinados por el parámetro Propiedad (así como por la mutabilidad del mensaje si autodetected
usa autodetected
para la Propiedad).
Como se decidió
En esta sección, consideraremos todos los componentes que conforman la solución final. Bueno, la solución resultante en sí. Se mostrarán los fragmentos de código borrados de todos los detalles que distraen. Si alguien está interesado en el código real, puede verlo aquí .
Descargo de responsabilidad
La solución que se muestra a continuación no pretende ser hermosa, ideal o un modelo a seguir. Fue encontrado, implementado, probado y documentado en poco tiempo, bajo la presión de los plazos. Tal vez si hubiera más tiempo y se dedicara más a la búsqueda de una solución. joven sensible y conocedor en el desarrollador moderno de C ++, resultaría ser más compacto, más simple y más comprensible. Pero, resultó que sucedió ... "No le dispares al pianista", en general.
Secuencia de pasos y plantilla mágica lista para usar
Entonces, necesitamos tener una clase con varios conjuntos de métodos. El contenido de estos kits debe provenir de alguna parte. De donde
En D, podríamos usar static if
y definir diferentes partes de la clase dependiendo de las diferentes condiciones. En algunos Ruby, podríamos mezclar métodos en nuestra clase usando el método include . Pero estamos en C ++, donde hasta ahora nuestras posibilidades son muy limitadas: podemos definir un método / atributo directamente dentro de la clase, o podemos heredar el método / atributo de alguna clase base.
No podemos definir diferentes métodos / atributos dentro de la clase dependiendo de alguna condición, porque C ++ if constexpr
no es una static if
D static if
. En consecuencia, solo queda la herencia.
Upd. Como se sugiere en los comentarios, debería hablar más cuidadosamente aquí. Como C ++ tiene SFINAE, podemos habilitar / deshabilitar la visibilidad de los métodos individuales en la clase a través de SFINAE (es decir, lograr un efecto similar al static if
). Pero este enfoque tiene dos graves, en mi opinión, deficiencias. En primer lugar, si dichos métodos no son 1-2-3, sino 4-5 o más, es tedioso formatear cada uno de ellos usando SFINAE, y esto afecta la legibilidad del código. En segundo lugar, SFINAE no nos ayuda a agregar / eliminar atributos de clase (campos).
En C ++, podemos definir varias clases base de las cuales message_holder_t
. Y la elección de una u otra clase base ya se realizará dependiendo de los valores de los parámetros de la plantilla, usando std :: conditional .
Pero el truco es que no solo necesitamos un conjunto de clases base, sino una pequeña cadena de herencia. Al principio habrá una clase que determinará la funcionalidad general que se requiere en cualquier caso. A continuación serán las clases base que determinarán la lógica del comportamiento del "puntero inteligente". Y luego habrá una clase que determine los captadores necesarios. En este orden consideraremos las clases implementadas.
Nuestra tarea se simplifica por el hecho de que SObjectizer ya tiene una plantilla de magia preparada que determina si un mensaje se hereda de message_t , así como medios para verificar la mutabilidad del mensaje . Por lo tanto, en la implementación, simplemente usaremos esta magia ya hecha y no profundizaremos en los detalles de su trabajo.
Base de almacenamiento de puntero común
Comencemos con un tipo base común que almacena el intrusive_ptr correspondiente, y también proporciona un conjunto común de métodos que necesita cualquiera de las implementaciones de 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(); } };
Esta clase de plantilla tiene dos parámetros. El primero, Carga útil, establece el tipo que deben usar los métodos getter. Mientras que el segundo, Envelope, establece el tipo para intrusive_ptr. En el caso de que el tipo de mensaje se herede de message_t
ambos parámetros tendrán el mismo valor. Pero si el mensaje no se hereda de message_t
, entonces el tipo de mensaje se usará como Payload, y user_type_message_t<Payload>
actuará como Envelope.
Creo que básicamente el contenido de esta clase no plantea preguntas. Pero dos cosas deben notarse por separado.
En primer lugar, el puntero en sí, es decir El atributo m_msg se define en la sección protegida para que los herederos de las clases tengan acceso a él.
En segundo lugar, para esta clase, el compilador genera todos los constructores y operadores de copia / movimiento necesarios. Y a nivel de esta clase, todavía no estamos prohibiendo nada.
Bases separadas para el comportamiento shared_ptr y unique_ptr
Entonces, tenemos una clase que almacena un puntero a un mensaje. Ahora podemos definir sus herederos, que se comportarán como shared_ptr o como unique_ptr.
Comencemos con el caso del comportamiento shared_ptr, porque Aquí está el código mínimo:
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; } };
Nada complicado: heredar de basic_message_holder_impl_t
, heredar todos sus constructores y definir una implementación simple y no destructiva de make_reference()
.
Para el caso de unique_ptr-behaviour, el código es más grande, aunque no tiene nada de complicado:
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) }; } };
Nuevamente, heredamos de basic_message_holder_impl_t
y heredamos los constructores que necesitamos de él (este es el constructor predeterminado y el constructor de inicialización). Pero al mismo tiempo, definimos los constructores y los operadores de copia / movimiento de acuerdo con la lógica unique_ptr: prohibimos la copia, implementamos el movimiento.
También tenemos un make_reference()
destructivo make_reference()
.
Eso es todo, en realidad. Solo queda darse cuenta de la elección entre estas dos clases base ...
Elegir entre el comportamiento shared_ptr y unique_ptr
Para elegir entre el comportamiento shared_ptr y unique_ptr, necesita la siguiente metafunción (metafunción porque "funciona" con tipos en tiempo de compilación):
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> > >; };
Esta metafunción acepta ambos parámetros de la lista de parámetros message_holder_t
y, como resultado (es decir, la definición de un type
anidado), "devuelve" el tipo del que debe heredarse. Es decir shared_message_holder_impl_t
o unique_message_holder_impl_t
.
Dentro de la definición de impl_selector
puede ver rastros de la magia mencionada anteriormente, y en la que no entramos: message_payload_type<Msg>::payload_type
, message_payload_type<Msg>::envelope_type
y message_mutability_traits<Msg>::mutability
.
Y para usar impl_selector
fue más fácil, entonces definiremos un nombre más corto para él:
template< typename Msg, message_ownership_t Ownership > using impl_selector_t = typename impl_selector<Msg, Ownership>::type;
Base para captadores
Entonces, ya tenemos la oportunidad de seleccionar una base que contenga un puntero y defina el comportamiento de un "puntero inteligente". Ahora necesitamos proporcionar esta base con métodos getter. ¿Por qué necesitamos una clase simple:
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(); } };
Esta es una clase de plantilla que depende de dos parámetros, pero su significado es completamente diferente. El parámetro Base será el resultado de la impl_selector
impl_selector que se muestra arriba. Es decir como parámetro Base, se establece la clase base de la cual se hereda.
Es importante tener en cuenta que si la herencia proviene de unique_message_holder_impl_t
, para lo cual el constructor y el operador de copia están prohibidos, el compilador no podrá generar el constructor y el operador de copia para msg_accessors_t
. Que es lo que necesitamos.
El tipo de mensaje, el puntero / enlace al que serán devueltos por los captadores, actuará como el parámetro Return_Type. El truco es que para un mensaje inmutable de tipo Msg
el parámetro Return_Type se establecerá en const Msg
. Mientras que para un mensaje mutable de tipo Msg
parámetro Return_Type tendrá el valor Msg
. Por lo tanto, el método get()
devolverá const Msg*
para mensajes inmutables, y solo Msg*
para mensajes mutables.
Usando la función gratuita get_ptr()
resolvemos el problema de trabajar con mensajes que no se heredan de 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); }
Es decir si el mensaje no se hereda de message_t
y se almacena como user_type_message_t<Msg>
, se llama a la segunda sobrecarga. Y si se hereda, entonces la primera sobrecarga.
Elegir una base específica para captadores
Entonces, la plantilla msg_accessors_t
requiere dos parámetros. El primero se calcula mediante la impl_selector
impl_selector. Pero para formar un tipo base específico de msg_accessors_t
, necesitamos determinar el valor del segundo parámetro. Una metafunción más está destinada a esto:
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> >; };
Solo puede prestar atención al cálculo del parámetro Return_Type. Uno de esos pocos casos donde este const es útil;)
Bueno, para aumentar la legibilidad del siguiente código, una opción más compacta para trabajar con él:
template< message_mutability_t Mutability, typename Base > using accessor_selector_t = typename accessor_selector<Mutability, Base>::type;
Sucesor final message_holder_t
Ahora puede ver qué message_holder_t
, para cuya implementación se requirieron todas estas clases base y metafunciones (parte de los métodos para construir una instancia del mensaje almacenado en message_holder se eliminan de la implementación):
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() ); } };
De hecho, todo lo que analizamos anteriormente fue necesario para registrar esta "llamada" de dos metafunciones:
details::message_holder_details::accessor_selector_t< details::message_mutability_traits<Msg>::mutability, details::message_holder_details::impl_selector_t<Msg, Ownership> >
Porque Esta no es la primera opción, pero como resultado de la simplificación y reducción del código, puedo decir que las formas compactas de metafunciones reducen en gran medida la cantidad de código y aumentan su comprensión (si generalmente es apropiado hablar de comprensión aquí).
¿Y qué pasaría si ...
Pero si en C ++ if constexpr
era tan poderoso como static if
en D, entonces podría escribir algo como:
Versión hipotética con más avanzada si 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
.
Conclusión
, C++. , . , , .
, .
, , ++ , . , . , , . , . C++98/03 , C++11 .