Recientemente, mientras trabajaba en una nueva versión de SObjectizer , me enfrenté a la tarea de controlar las acciones del desarrollador en el momento de la compilación. La conclusión era que anteriormente un programador podía hacer llamadas de la forma:
receive(from(ch).empty_timeout(150ms), ...); receive(from(ch).handle_n(2).no_wait_on_empty(), ...); receive(from(ch).empty_timeout(2s).extract_n(20).stop_on(...), ...); receive(from(ch).no_wait_on_empty().stop_on(...), ...);
La operación de recepción () requirió un conjunto de parámetros, para los cuales se utilizó una cadena de métodos, como los mostrados anteriormente from(ch).empty_timeout(150ms)
o from(ch).handle_n(2).no_wait_on_empty()
. Al mismo tiempo, llamar a los métodos handle_n () / extract_n (), que limitan la cantidad de mensajes a extraer / procesar, era opcional. Por lo tanto, todas las cadenas mostradas arriba eran correctas.
Pero en la nueva versión, se requería forzar al usuario a indicar explícitamente el número de mensajes para extraer y / o procesar. Es decir una cadena del formulario from(ch).empty_timeout(150ms)
ahora se volvió incorrecta. Debe ser reemplazado por from(ch).handle_all().empty_timeout(150ms)
.
Y quería hacerlo para que el compilador venciera a la mano del programador si el programador olvidaba hacer una llamada a handle_all (), handle_n () o extract_n ().
¿Puede C ++ ayudar con esto?
Si Y si alguien está interesado en cómo exactamente, entonces eres bienvenido con Cat.
Hay más de una función de recepción ()
La función de recepción () se mostró anteriormente, cuyos parámetros se configuraron mediante una cadena de llamadas (también conocida como patrón de generador ). Pero también había una función select (), que recibió casi el mismo conjunto de parámetros:
select(from_all().empty_timeout(150ms), case_(...), case_(...), ...); select(from_all().handle_n(2).no_wait_on_empty(), case_(...), case_(...), ...); select(from_all().empty_timeout(2s).extract_n(20).stop_on(...), case_(...), case_(...), ...); select(from_all().no_wait_on_empty().stop_on(...), case_(...), case_(...), ...);
En consecuencia, quería obtener una solución que fuera adecuada tanto para select () como para reciben (). Además, los parámetros para select () y reciben () ya estaban representados en el código para evitar copiar y pegar. Pero esto se discutirá a continuación.
Posibles soluciones
Entonces, la tarea es que el usuario invoque handle_all (), handle_n () o extract_n () sin falta.
En principio, esto se puede lograr sin recurrir a decisiones complejas. Por ejemplo, podría ingresar un argumento adicional para select () y recibir ():
receive(handle_all(), from(ch).empty_timeout(150ms), ...); select(handle_n(20), from_all().no_wait_on_empty(), ...);
O sería posible obligar al usuario a hacer que la llamada de recepción () / selección () sea diferente:
receive(handle_all(from(ch).empty_timeout(150ms)), ...); select(handle_n(20, from_all().no_wait_on_empty()), ...);
Pero el problema aquí es que al cambiar a una nueva versión de SObjectizer, el usuario tendría que rehacer su código. Incluso si el código, en principio, no requería retrabajo. Digamos, en esta situación:
receive(from(ch).handle_n(2).no_wait_on_empty(), ...); select(from_all().empty_timeout(2s).extract_n(20).stop_on(...), case_(...), case_(...), ...);
Y esto, en mi opinión, es un problema muy grave. Lo que te hace buscar otra forma. Y este método se describirá a continuación.
Entonces, ¿dónde entra CRTP?
El título del artículo mencionaba CRTP. También es un patrón de plantilla curiosamente recurrente (aquellos que quieran familiarizarse con esta técnica interesante, pero ligeramente tolerante al cerebro, pueden comenzar con esta serie de publicaciones en el blog Fluent C ++).
Se mencionó CRTP porque a través de CRTP implementamos el trabajo con los parámetros de la función recibir () y seleccionar (). Como la mayor parte de los parámetros para recibir () y seleccionar () era la misma, el código usaba algo como esto:
template<typename Derived> class bulk_processing_params_t { ...;
¿Por qué está CRTP aquí?
Tuvimos que usar CRTP aquí para que los métodos de establecimiento que se definieron en la clase base pudieran devolver una referencia no al tipo base, sino al derivado.
Es decir, si no se utilizó CRTP, sino una herencia ordinaria, entonces solo podríamos escribir así:
class bulk_processing_params_t { public:
Pero un mecanismo tan primitivo no nos permitirá usar el mismo patrón de construcción, porque:
receive_processing_params_t{}.handle_n(20).receive_payload(0)
No compilado. El método handle_n () devolverá una referencia a bulk_processing_params_t, y allí todavía no está definido el método generate_payload ().
Pero con CRTP no tenemos problemas con el patrón de construcción.
Decisión final
La solución final es que los tipos finales, tales como recibir_procesamiento_parámetros_t y select_processing_params_t, se conviertan en tipos de plantilla. Para que se parametricen con un escalar de la siguiente forma:
enum class msg_count_status_t { undefined, defined };
Y para que el tipo final se pueda convertir de T <msg_count_status_t :: undefined> a T <msg_count_status_t :: defined>.
Esto permitirá, por ejemplo, en la función recibir () recibir recibir_procesamiento_parámetros_t y verificar el valor de estado en tiempo de compilación. Algo como:
template< msg_count_status_t Msg_Count_Status, typename... Handlers > inline mchain_receive_result_t receive( const mchain_receive_params_t<Msg_Count_Status> & params, Handlers &&... handlers ) { static_assert( Msg_Count_Status == msg_count_status_t::defined, "message count to be processed/extracted should be defined " "by using handle_all()/handle_n()/extract_n() methods" );
En general, todo es simple, como siempre: tomar y hacer;)
Descripción de la decisión tomada.
Veamos un ejemplo mínimo, separado de los detalles de SObjectizer, tal como se ve.
Entonces, ya tenemos un tipo que determina si el límite en la cantidad de mensajes está establecido o no:
enum class msg_count_status_t { undefined, defined };
A continuación, necesitamos una estructura en la que se almacenarán todos los parámetros comunes:
struct basic_data_t { int to_extract_{}; int to_handle_{}; int common_payload_{}; };
En general, no importa cuál sea el contenido de basic_data_t. Por ejemplo, el conjunto mínimo de campos que se muestra arriba es adecuado.
En relación con basic_data_t, es importante que para operaciones específicas (ya sea recibir (), seleccionar () u otra cosa), se creará su propio tipo concreto que herede basic_data_t. Por ejemplo, para recibir () en nuestro ejemplo abstracto, esta sería la siguiente estructura:
struct receive_specific_data_t final : public basic_data_t { int receive_payload_{}; receive_specific_data_t() = default; receive_specific_data_t(int v) : receive_payload_{v} {} };
Suponemos que la estructura basic_data_t y sus descendientes no causan dificultades. Por lo tanto, pasamos a las partes más complejas de la solución.
Ahora necesitamos un contenedor alrededor de basic_data_t, que proporcionará métodos getter. Esta será una clase de plantilla de la siguiente forma:
template<typename Basic_Data> class basic_data_holder_t { private : Basic_Data data_; protected : void set_to_extract(int v) { data_.to_extract_ = v; } void set_to_handle(int v) { data_.to_handle_ = v; } void set_common_payload(int v) { data_.common_payload_ = v; } const auto & data() const { return data_; } public : basic_data_holder_t() = default; basic_data_holder_t(Basic_Data data) : data_{std::move(data)} {} int to_extract() const { return data_.to_extract_; } int to_handle() const { return data_.to_handle_; } int common_payload() const { return data_.common_payload_; } };
Esta clase es repetitiva, por lo que puede contener cualquier heredero de basic_data_t, aunque implementa métodos getter solo para aquellos campos que están en basic_data_t.
Antes de pasar a las partes aún más complejas de la solución, debe prestar atención al método data () en basic_data_holder_t. Este es un método importante y lo encontraremos más adelante.
Ahora podemos pasar a la clase de plantilla de clave, que puede parecer bastante aterradora para las personas que no están muy dedicadas al C ++ moderno:
template<typename Data, typename Derived> class basic_params_t : public basic_data_holder_t<Data> { using base_type = basic_data_holder_t<Data>; public : using actual_type = Derived; using data_type = Data; protected : actual_type & self_reference() { return static_cast<actual_type &>(*this); } decltype(auto) clone_as_defined() { return self_reference().template clone_if_necessary< msg_count_status_t::defined >(); } public : basic_params_t() = default; basic_params_t(data_type data) : base_type{std::move(data)} {} decltype(auto) handle_all() { this->set_to_handle(0); return clone_as_defined(); } decltype(auto) handle_n(int v) { this->set_to_handle(v); return clone_as_defined(); } decltype(auto) extract_n(int v) { this->set_to_extract(v); return clone_as_defined(); } actual_type & common_payload(int v) { this->set_common_payload(v); return self_reference(); } using base_type::common_payload; };
Este basic_params_t es la plantilla CRTP principal. Solo que ahora está parametrizado por dos parámetros.
El primer parámetro es el tipo de datos que debe estar contenido dentro. Por ejemplo, recibir_datos_específicos_t o seleccionar_datos_específicos_t.
El segundo parámetro es el tipo de sucesor familiar para CRTP. Se utiliza en el método self_reference () para obtener una referencia a un tipo derivado.
El punto clave en la implementación de la plantilla basic_params_t es su método clone_as_defined (). Este método espera que el heredero implemente el método clone_if_necessary (). Y este clone_if_necessary () está diseñado para transformar el objeto T <msg_count_status_t :: undefined> en el objeto T <msg_count_status_t :: defined>. Y dicha transformación se inicia en los métodos setter handle_all (), handle_n () y extract_n ().
Además, puede prestar atención al hecho de que clone_as_defined (), handle_all (), handle_n () y extract_n () determinan el tipo de su valor de retorno como decltype (auto). Este es otro truco, del que hablaremos pronto.
Ahora ya podemos ver uno de los tipos finales, para lo cual todo esto fue concebido:
template< msg_count_status_t Msg_Count_Status > class receive_specific_params_t final : public basic_params_t< receive_specific_data_t, receive_specific_params_t<Msg_Count_Status> > { using base_type = basic_params_t< receive_specific_data_t, receive_specific_params_t<Msg_Count_Status> >; public : template<msg_count_status_t New_Msg_Count_Status> std::enable_if_t< New_Msg_Count_Status != Msg_Count_Status, receive_specific_params_t<New_Msg_Count_Status> > clone_if_necessary() const { return { this->data() }; } template<msg_count_status_t New_Msg_Count_Status> std::enable_if_t< New_Msg_Count_Status == Msg_Count_Status, receive_specific_params_t& > clone_if_necessary() { return *this; } receive_specific_params_t(int receive_payload) : base_type{ typename base_type::data_type{receive_payload} } {} receive_specific_params_t(typename base_type::data_type data) : base_type{ std::move(data) } {} int receive_payload() const { return this->data().receive_payload_; } };
Lo primero a lo que debe prestar atención aquí es al constructor, que toma base_type :: data_type. Con este constructor, los valores de los parámetros actuales se transfieren durante la transformación de T <msg_count_status_t :: undefined> a T <msg_count_status_t :: defined>.
En general, este método de recepción de parámetros específicos es algo como esto:
template<typename V, int K> class holder_t { V v_; public: holder_t() = default; holder_t(V v) : v_{std::move(v)} {} const V & value() const { return v_; } }; holder_t<std::string, 0> v1{"Hello!"}; holder_t<std::string, 1> v2; v2 = v1;
Y solo el constructor anterior recibir_específicos_para_tipos_t le permite inicializar recibir_paramétricos_específicos_t <msg_count_status_t :: defined> con valores de reciba_specific_params_t <msg_count_status_t :: undefined>.
La segunda cosa importante en Receive_specific_params_t son los dos métodos clone_if_necessary ().
¿Por qué hay dos? ¿Y qué significa toda esta magia SFINAE-vskaya en su definición?
Se han realizado dos métodos clone_if_necessary () para evitar transformaciones innecesarias. Supongamos que un programador llamó al método handle_n () y ya recibió recibir_específicos_params_t <msg_count_status_t :: defined>. Y luego llamó extract_n (). Esto está permitido, handle_n () y extract_n () establecen restricciones ligeramente diferentes. La llamada a extract_n () también debería darnos accept_specific_params_t <msg_count_status_t :: defined>. Pero ya tenemos uno. Entonces, ¿por qué no reutilizar uno existente?
Es por eso que hay dos métodos clone_if_necessary () aquí. El primero funcionará cuando la transformación sea realmente necesaria:
template<msg_count_status_t New_Msg_Count_Status> std::enable_if_t< New_Msg_Count_Status != Msg_Count_Status, receive_specific_params_t<New_Msg_Count_Status> > clone_if_necessary() const { return { this->data() }; }
El compilador lo seleccionará, por ejemplo, cuando el estado cambie de indefinido a definido. Y este método devolverá un nuevo objeto. Y sí, en la implementación de este método, prestamos atención a la llamada data (), que ya estaba definida en basic_data_holder_t.
El segundo método:
template<msg_count_status_t New_Msg_Count_Status> std::enable_if_t< New_Msg_Count_Status == Msg_Count_Status, receive_specific_params_t& > clone_if_necessary() { return *this; }
se llamará cuando no sea necesario cambiar el estado. Y este método devuelve una referencia a un objeto existente.
Ahora debería quedar claro por qué en basic_params_t para varios métodos, el tipo de retorno se definió como decltype (auto). Después de todo, estos métodos dependen de qué versión particular de clone_if_necessary () se llamará en el tipo derivado, y allí se puede devolver un objeto o un enlace ... no se puede predecir de antemano. Y aquí el decltype (auto) viene al rescate.
Pequeño descargo de responsabilidad
El ejemplo minimalista descrito estaba dirigido a la demostración más simple y comprensible de la solución elegida. Por lo tanto, no tiene cosas bastante obvias que piden ser incluidas en el código.
Por ejemplo, el método basic_data_holder_t :: data () devuelve una referencia constante a los datos. Esto lleva a la copia de los valores de los parámetros durante la transformación de T <msg_count_status_t :: undefined> a T <msg_count_status_t :: defined>. Si copiar parámetros es una operación costosa, entonces debería estar desconcertado por la semántica de movimiento y el método data () podría tener la siguiente forma:
auto data() { return std::move(data_); }
Además, ahora, en cada tipo final (como reciben_específicos_params_t y select_específicos_params_t), debe incluir implementaciones de los métodos clone_if_necesarios. Es decir en este lugar todavía usamos copiar y pegar. Quizás también debería haber algo con lo que pensar para evitar la duplicación del mismo tipo de código.
Bueno, sí, no se exceptúa noexcept en el código para reducir la "sobrecarga de sintaxis".
Eso es todo
El código fuente para el ejemplo minimalista discutido aquí se puede encontrar aquí . Y puede jugar en el compilador en línea, por ejemplo, aquí (puede comentar la llamada a handle_all () en la línea 163 y ver qué sucede).
No quiero decir que el enfoque que implementé es el único correcto. Pero, en primer lugar, vi una alternativa a menos que sea copiar y pegar. Y, en segundo lugar, no fue nada difícil hacer esto, y afortunadamente, no tomó mucho tiempo. Pero los golpes del compilador ayudaron mucho de inmediato, ya que las pruebas y ejemplos anteriores se adaptaron a las nuevas características de la última versión de SObjectizer.
Entonces, en cuanto a mí, C ++ ha confirmado una vez más que es complejo. Pero no solo así, sino para dar más oportunidades al desarrollador. Bueno, no me sorprendería si todo esto se pudiera obtener en C ++ moderno de una manera aún más simple que yo.
PS. Si uno de los lectores sigue el SObjectizer, entonces puedo decir que la nueva versión 5.6, en la que se violó significativamente la compatibilidad con la rama 5.5, ya ha respirado bastante. Puedes encontrarlo en BitBucket . El lanzamiento aún está muy lejos, pero SObjectizer-5.6 ya es lo que estaba destinado a ser. Puede tomar, probar y compartir sus impresiones.