Recentemente, enquanto trabalhava em uma nova versão do SObjectizer , tive a tarefa de controlar as ações do desenvolvedor no tempo de compilação. A linha inferior era que anteriormente um programador poderia fazer chamadas do formulário:
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(...), ...);
A operação receive () exigia um conjunto de parâmetros, para os quais uma cadeia de métodos foi usada, como os mostrados acima from(ch).empty_timeout(150ms)
ou from(ch).handle_n(2).no_wait_on_empty()
. Ao mesmo tempo, chamar os métodos handle_n () / extract_n (), que limitam o número de mensagens a serem extraídas / processadas, era opcional. Portanto, todas as cadeias mostradas acima estavam corretas.
Mas na nova versão, era necessário forçar o usuário a indicar explicitamente o número de mensagens a serem extraídas e / ou processadas. I.e. uma cadeia do formulário from(ch).empty_timeout(150ms)
agora ficou incorreta. Ele deve ser substituído por from(ch).handle_all().empty_timeout(150ms)
.
E eu queria fazer com que o compilador vencesse o programador manualmente se o programador esquecesse de fazer uma chamada para handle_all (), handle_n () ou extract_n ().
O C ++ pode ajudar com isso?
Sim E se alguém estiver interessado em exatamente como, então você é bem-vindo no gato.
Há mais do que uma função receive ()
A função receive () foi mostrada acima, cujos parâmetros foram definidos usando uma cadeia de chamadas (também conhecida como padrão do construtor ). Mas havia também uma função select (), que recebia quase o mesmo 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_(...), ...);
Consequentemente, eu queria obter uma solução que fosse adequada para select () e receive (). Além disso, os parâmetros para select () e receive () já estavam representados no código para evitar copiar e colar. Mas isso será discutido abaixo.
Possíveis soluções
Portanto, a tarefa é para o usuário chamar handle_all (), handle_n () ou extract_n () sem falha.
Em princípio, isso pode ser alcançado sem recorrer a decisões complexas. Por exemplo, você pode inserir um argumento adicional para select () e receive ():
receive(handle_all(), from(ch).empty_timeout(150ms), ...); select(handle_n(20), from_all().no_wait_on_empty(), ...);
Ou seria possível forçar o usuário a fazer a chamada receive () / select () de maneira diferente:
receive(handle_all(from(ch).empty_timeout(150ms)), ...); select(handle_n(20, from_all().no_wait_on_empty()), ...);
Mas o problema aqui é que, ao mudar para uma nova versão do SObjectizer, o usuário precisará refazer seu código. Mesmo que o código, em princípio, não exigisse retrabalho. Diga, nesta situação:
receive(from(ch).handle_n(2).no_wait_on_empty(), ...); select(from_all().empty_timeout(2s).extract_n(20).stop_on(...), case_(...), case_(...), ...);
E isso, na minha opinião, é um problema muito sério. O que faz você procurar outro caminho. E este método será descrito abaixo.
Então, de onde vem o CRTP?
O título do artigo mencionou o CRTP. Ele também é um modelo de modelo curiosamente recorrente (aqueles que desejam se familiarizar com essa técnica interessante, mas um pouco tolerante ao cérebro, podem começar com essa série de postagens no blog do Fluent C ++).
O CRTP foi mencionado porque, por meio dele, implementamos o trabalho com os parâmetros de função receive () e select (). Como a maior parte dos parâmetros para receive () e select () era a mesma, o código usava algo como isto:
template<typename Derived> class bulk_processing_params_t { ...;
Por que o CRTP está aqui?
Tivemos que usar o CRTP aqui para que os métodos setter definidos na classe base pudessem retornar uma referência não ao tipo de base, mas ao tipo derivado.
Ou seja, se não fosse o CRTP usado, mas a herança comum, poderíamos escrever apenas assim:
class bulk_processing_params_t { public:
Mas esse mecanismo primitivo não nos permitirá usar o mesmo padrão de construtor, porque:
receive_processing_params_t{}.handle_n(20).receive_payload(0)
não compilado. O método handle_n () retornará uma referência para bulk_processing_params_t, e o método receive_payload () ainda não está definido.
Mas com o CRTP, não temos problemas com o padrão do construtor.
Decisão final
A solução final é que os tipos finais, como receive_processing_params_t e select_processing_params_t, se tornem os próprios tipos de modelo. Para que eles sejam parametrizados com um escalar do seguinte formato:
enum class msg_count_status_t { undefined, defined };
E para que o tipo final possa ser convertido de T <msg_count_status_t :: undefined> para T <msg_count_status_t :: defined>.
Isso permitirá, por exemplo, na função receive () receber receive_processing_params_t e verificar o valor Status em tempo de compilação. 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" );
Em geral, tudo é simples, como de costume: pegue e faça;)
Descrição da decisão tomada
Vejamos um exemplo mínimo, separado das especificidades do SObjectizer, como parece.
Portanto, já temos um tipo que determina se o limite do número de mensagens está definido ou não:
enum class msg_count_status_t { undefined, defined };
Em seguida, precisamos de uma estrutura na qual todos os parâmetros comuns sejam armazenados:
struct basic_data_t { int to_extract_{}; int to_handle_{}; int common_payload_{}; };
Geralmente, não importa qual será o conteúdo de basic_data_t. Por exemplo, o conjunto mínimo de campos mostrado acima é adequado.
Em relação a basic_data_t, é importante que, para operações específicas (seja recebida (), selecione () ou outra coisa)), seja criado seu próprio tipo concreto que herda basic_data_t. Por exemplo, para receive () em nosso exemplo abstraído, essa seria a seguinte estrutura:
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} {} };
Assumimos que a estrutura basic_data_t e seus descendentes não causem dificuldades. Portanto, passamos às partes mais complexas da solução.
Agora precisamos de um wrapper em torno de basic_data_t, que fornecerá métodos getter. Essa será uma classe de modelo do seguinte formulário:
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_; } };
Essa classe é padronizada para que possa conter qualquer descendente de basic_data_t, embora implemente métodos getter apenas para os campos que estão em basic_data_t.
Antes de avançarmos para as partes ainda mais complexas da solução, você deve prestar atenção ao método data () em basic_data_holder_t. Este é um método importante e vamos encontrá-lo mais tarde.
Agora podemos passar para a classe de modelo de chave, que pode parecer bastante assustadora para pessoas que não são muito dedicadas ao 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 é o principal modelo CRTP. Somente agora é parametrizado por dois parâmetros.
O primeiro parâmetro é o tipo de dados que deve estar contido. Por exemplo, receive_specific_data_t ou selecione_specific_data_t.
O segundo parâmetro é o tipo de sucessor familiar ao CRTP. É usado no método self_reference () para obter uma referência a um tipo derivado.
O ponto chave na implementação do modelo basic_params_t é o método clone_as_defined (). Este método espera que o herdeiro implemente o método clone_if_necessary (). E esse clone_if_necessary () foi projetado apenas para transformar o objeto T <msg_count_status_t :: undefined> no objeto T <msg_count_status_t :: defined>. E essa transformação é iniciada nos métodos setter handle_all (), handle_n () e extract_n ().
Além disso, você pode prestar atenção ao fato de que clone_as_defined (), handle_all (), handle_n () e extract_n () determinam o tipo de seu valor de retorno como decltype (auto). Esse é outro truque, sobre o qual falaremos em breve.
Agora já podemos olhar para um dos tipos finais, para os quais tudo isso foi 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_; } };
A primeira coisa que você deve prestar atenção aqui é o construtor, que usa base_type :: data_type. Usando esse construtor, os valores atuais dos parâmetros são transferidos durante a transformação de T <msg_count_status_t :: undefined> para T <msg_count_status_t :: defined>.
Em geral, esse receive_specific_params_t é algo como isto:
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;
E apenas o construtor acima receive_specific_params_t permite inicializar receive_specific_params_t <msg_count_status_t :: defined> com valores de receive_specific_params_t <msg_count_status_t :: undefined>.
A segunda coisa importante em receive_specific_params_t são os dois métodos clone_if_necessary ().
Por que existem dois? E o que toda essa mágica SFINAE-vskaya significa em sua definição?
Dois métodos clone_if_necessary () foram criados para evitar transformações desnecessárias. Suponha que um programador chamado método handle_n () e já tenha recebido receive_specific_params_t <msg_count_status_t :: defined>. E então chamou extract_n (). Isso é permitido, handle_n () e extract_n () definem restrições ligeiramente diferentes. A chamada para extract_n () também deve nos dar um número_de_específicos_específicos <msg_count_status_t :: defined>. Mas nós já temos um. Então, por que não reutilizar um existente?
É por isso que existem dois métodos clone_if_necessary () aqui. O primeiro funcionará quando a transformação for realmente necessária:
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() }; }
O compilador irá selecioná-lo, por exemplo, quando o status for alterado de indefinido para definido. E esse método retornará um novo objeto. E sim, na implementação desse método, prestamos atenção à chamada data (), que já foi definida em basic_data_holder_t.
O 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; }
será chamado quando não for necessário alterar o status. E esse método retorna uma referência a um objeto existente.
Agora deve ficar claro por que, em basic_params_t, para vários métodos, o tipo de retorno foi definido como decltype (auto). Afinal, esses métodos dependem de qual versão específica de clone_if_necessary () será chamada no tipo derivado, e um objeto ou um link pode ser retornado ... você não pode prever com antecedência. E aqui decltype (auto) vem em socorro.
Isenção de responsabilidade pequena
O exemplo minimalista descrito teve como objetivo a demonstração mais simples e compreensível da solução escolhida. Portanto, não há coisas óbvias que impliquem para serem incluídas no código.
Por exemplo, o método basic_data_holder_t :: data () retorna uma referência constante aos dados. Isso leva à cópia dos valores dos parâmetros durante a transformação de T <msg_count_status_t :: undefined> para T <msg_count_status_t :: defined>. Se a cópia de parâmetros for uma operação cara, você deve ficar intrigado com a semântica de movimentação e o método data () pode ter o seguinte formato:
auto data() { return std::move(data_); }
Agora também, em todos os tipos finais (como receive_specific_params_t e select_specific_params_t), você deve incluir implementações dos métodos clone_if_necessary. I.e. neste local, ainda usamos copiar e colar. Talvez também deva ser elaborado algo para evitar a duplicação do mesmo tipo de código.
Bem, sim, noexcept não é colocado no código para reduzir a "sobrecarga de sintaxe".
Isso é tudo
O código fonte do exemplo minimalista discutido aqui pode ser encontrado aqui . E você pode jogar no compilador on-line, por exemplo, aqui (você pode comentar a chamada para handle_all () na linha 163 e ver o que acontece).
Não quero dizer que a abordagem que implementei é a única correta. Mas, primeiro, vi uma alternativa, a menos que em copiar e colar. E, em segundo lugar, não foi nada difícil de fazer e, felizmente, não demorou muito tempo. Mas os socos do compilador ajudaram muito imediatamente, pois os testes e exemplos antigos se adaptaram aos novos recursos da versão mais recente do SObjectizer.
Então, quanto a mim, o C ++ confirmou mais uma vez que é complexo. Mas não é só isso, mas para dar mais oportunidades ao desenvolvedor. Bem, não ficarei surpreso se tudo isso puder ser obtido no C ++ moderno de uma maneira ainda mais simples do que eu.
PS. Se um dos leitores seguir o SObjectizer, posso dizer que a nova versão 5.6, na qual a compatibilidade com o ramo 5.5 foi significativamente violada, já respirou bastante. Você pode encontrá-lo no BitBucket . O lançamento ainda está muito longe, mas o SObjectizer-5.6 já é o que deveria ser. Você pode tirar, experimentar e compartilhar suas impressões.