Lugares escorregadios em C ++ 17

imagem

Nos últimos anos, o C ++ deu trancos e barrancos, e acompanhar todas as sutilezas e complexidades da linguagem pode ser muito, muito difícil. Um novo padrão não está muito longe, no entanto, a introdução de novas tendências não é o processo mais rápido e fácil, portanto, embora haja pouco tempo antes do C ++ 20, sugiro atualizar ou descobrir alguns lugares especialmente "escorregadios" do padrão atual idioma.

Hoje, vou lhe dizer por que, se o constexpr não substitui as macros, quais são os "elementos internos" da ligação estruturada e suas "armadilhas" e é verdade que a cópia elision sempre funciona agora e você pode escrever qualquer retorno sem hesitação.

Se você não tem medo de sujar um pouco as mãos, investigando o "interior" da sua língua, bem-vindo ao gato.



se constexpr


Vamos começar pelo mais simples - if constexpr permitir descartar o ramo de expressão condicional para o qual a condição desejada não é atendida, mesmo no estágio de compilação.

Parece que isso substitui a macro #if para desativar a lógica "extra"? Não. Nem um pouco.

Primeiro, tal if tem propriedades que não estão disponíveis para macros - dentro você pode contar qualquer expressão constexpr que possa ser constexpr em bool . Bem, e em segundo lugar, o conteúdo do ramo descartado deve estar sintático e semanticamente correto.

Por causa do segundo requisito, if constexpr não puder ser usado, por exemplo, funções inexistentes (o código dependente da plataforma não pode ser explicitamente separado dessa maneira) ou incorreto do ponto de vista da linguagem de construção (por exemplo, “ void T = 0; ”).

Qual é o sentido de usar if constexpr ? O ponto principal está nos modelos. Existe uma regra especial para eles: o ramo descartado não é instanciado quando o modelo é instanciado. Isso facilita a gravação de código que, de alguma forma, depende das propriedades dos tipos de modelo.

No entanto, nos modelos, não se deve esquecer que o código dentro dos ramos deve estar correto pelo menos para algumas instâncias (mesmo que potencialmente static_assert(false) ) de instanciação; portanto, é simplesmente static_assert(false) escrever, por exemplo, static_assert(false) dentro de um dos ramos (é necessário que isso static_assert dependia de algum parâmetro dependente do modelo).

Exemplos:

 void foo() {    //    ,       if constexpr ( os == OS::win ) {        win_api_call(); //         }    else {        some_other_os_call(); //  win      } } 

 template<class T> void foo() {    //    ,    T      if constexpr ( os == OS::win ) {        T::win_api_call(); //  T   ,    win    }    else {        T::some_other_os_call(); //  T   ,         } } 

 template<class T> void foo() {    if constexpr (condition1) {        // ...    }    else if constexpr (condition2) {        // ...    }    else {        // static_assert(false); //          static_assert(trait<T>::value); // ,   ,  trait<T>::value   false    } } 

Coisas para lembrar


  1. O código em todas as ramificações deve estar correto.
  2. Nos modelos internos, o conteúdo das ramificações descartadas não é instanciado.
  3. O código dentro de qualquer ramificação deve estar correto para pelo menos uma variante puramente potencial de instanciação do modelo.

Ligação estruturada




No C ++ 17, apareceu um mecanismo bastante conveniente para decompor vários objetos do tipo tupla, permitindo vincular de forma conveniente e concisa seus elementos internos a variáveis ​​nomeadas:

 //     —    : for (const auto& [key, value] : map) {    std::cout << key << ": " << value << std::endl; } 

Por um objeto semelhante a uma tupla, entenderei um objeto para o qual seja conhecido o número de elementos internos disponíveis no momento da compilação (a partir de "tupla" - uma lista ordenada com um número fixo de elementos (vetor)).

Essas definições se enquadram nessa definição como: std::pair , std::tuple , std::array , matrizes no formato “ T a[N] ”, bem como várias estruturas e classes auto-escritas.

Pare ... Você pode usar suas próprias estruturas na ligação estrutural? Spoiler: você pode (embora às vezes precise trabalhar duro (mas mais sobre isso abaixo)).

Como isso funciona


O trabalho de vinculação estrutural merece um artigo separado, mas, como estamos falando especificamente de lugares “escorregadios”, tentarei explicar brevemente como tudo funciona.

O padrão fornece a seguinte sintaxe para definir a ligação:

attr (opcional) expressão de cv-auto ref-operator (opcional) [ identifier-list ];

  • attr - lista de atributos opcional;
  • cv-auto - auto com possíveis modificadores const / voláteis;
  • ref-operator - especificador de referência opcional (& ou &&);
  • identifier-list - uma lista de nomes de novas variáveis;
  • expression é uma expressão que resulta em um objeto semelhante a uma tupla usado para ligação (a expressão pode estar no formato " = expr ", " {expr} " ou " (expr) ").

É importante observar que o número de nomes na identifier-list deve corresponder ao número de elementos no objeto resultante da expression .

Isso tudo permite que você escreva construções do formulário:

 const volatile auto && [a,b,c] = Foo{}; 

E aqui chegamos ao primeiro local “escorregadio”: encontrar uma expressão da forma “ auto a = expr; ", Você normalmente quer dizer que o tipo" a "será calculado pela expressão" expr "e espera que na expressão" const auto& [a,b,c] = expr; "O mesmo será feito, apenas os tipos para" a,b,c "serão os tipos const& elemento correspondentes de" expr "...

A verdade é diferente: o especificador “ cv-auto ref-operator ” é usado para calcular o tipo de uma variável invisível, na qual o resultado do cálculo de expr é atribuído (ou seja, o compilador substitui “ const auto& [a,b,c] = expr ” por “ const auto& e = expr ").

Assim, uma nova entidade invisível aparece (a seguir denominarei {e}); no entanto, a entidade é muito útil: por exemplo, ela pode materializar objetos temporários (portanto, você pode conectá-los com segurança " const auto& [a,b,c] = Foo {}; ").

O segundo local escorregadio segue imediatamente a substituição que o compilador faz: se o tipo deduzido para {e} não for uma referência, o resultado de expr será copiado para {e}.

Quais tipos as variáveis ​​terão na identifier-list ? Para começar, essas não serão exatamente variáveis. Sim, eles se comportam como variáveis ​​comuns reais, mas apenas com a diferença de que eles se referem a uma entidade associada a eles e o tipo de decltype dessa variável de "referência" produzirá o tipo de entidade a que essa variável se refere:

 std::tuple<int, float> t(1, 2.f); auto& [a, b] = t; // decltype(a) — int, decltype(b) — float ++a; // ,  « »,   t std::cout << std::get<0>(t); //  2 

Os próprios tipos são definidos da seguinte maneira:

  1. Se {e} for uma matriz ( T a[N] ), o tipo será um - T, os modificadores cv coincidirão com os da matriz.
  2. Se {e} for do tipo E e suportar a interface da tupla, as estruturas serão definidas:

     std::tuple_size<E> 

     std::tuple_element<i, E> 

    e função:

     get<i>({e}); //  {e}.get<i>() 

    então o tipo de cada variável será o tipo std::tuple_element_t<i, E>
  3. Em outros casos, o tipo da variável corresponderá ao tipo de elemento da estrutura ao qual a ligação é realizada.

Portanto, se for muito breve, as seguintes etapas são executadas com o vínculo estrutural:

  1. Cálculo do tipo e inicialização da entidade invisível {e} com base nos modificadores de tipo expr e cv-ref .
  2. Crie pseudo-variáveis ​​e associe-as a {e} elementos.

Vincular estruturalmente suas classes / estruturas


O principal obstáculo para vincular suas estruturas é a falta de reflexão em C ++. Até o compilador, que, ao que parece, deve saber com certeza como essa ou aquela estrutura é organizada, tem dificuldade: modificadores de acesso (público / privado / protegido) e herança complicam bastante as coisas.

Devido a essas dificuldades, as restrições ao uso de suas classes são muito rígidas (pelo menos por enquanto: P1061 , P1096 ):

  1. Todos os campos não estáticos internos de uma classe devem ser da mesma classe base e devem estar disponíveis no momento do uso.
  2. Ou a classe deve implementar "reflexão" (suporte à interface da tupla).

 //  «»  struct A { int a; }; struct B : A {}; struct C : A { int c; }; class D { int d; }; auto [a] = A{}; //  (a -> A::a) auto [a] = B{}; //  (a -> B::A::a) auto [a, c] = C{}; // : a  c    auto [d] = D{}; // : d — private void D::foo() {    auto [d] = *this; //  (d   ) } 

A implementação da interface de tupla permite que você use qualquer uma de suas classes para ligação, mas ela parece um pouco complicada e traz outra armadilha. Vamos usar imediatamente um exemplo:

 //  ,      int   class Foo; template<> struct std::tuple_size<Foo> : std::integral_constant<std::size_t, 1> {}; template<> struct std::tuple_element<0, Foo> { using type = int&; }; class Foo { public: template<std::size_t i> std::tuple_element_t<i, Foo> const& get() const; template<std::size_t i> std::tuple_element_t<i, Foo> & get(); private: int _foo = 0; int& _bar = _foo; }; template<> std::tuple_element_t<0, Foo> const& Foo::get<0>() const { return _bar; } template<> std::tuple_element_t<0, Foo> & Foo::get<0>() { return _bar; } 

Agora ligamos:

 Foo foo; const auto& [f1] = foo; const auto [f2] = foo; auto& [f3] = foo; auto [f4] = foo; 

E é hora de pensar em quais tipos temos? (Quem poderia responder imediatamente merece um delicioso docinho.)

 decltype(f1); decltype(f2); decltype(f3); decltype(f4); 

Resposta correta
 decltype(f1); // int& decltype(f2); // int& decltype(f3); // int& decltype(f4); // int& ++f1; //     foo._foo,  {e}    const 


Por que isso aconteceu? A resposta está na especialização padrão para std::tuple_element :

 template<std::size_t i, class T> struct std::tuple_element<i, const T> { using type = std::add_const_t<std::tuple_element_t<i, T>>; }; 

std::add_const não adiciona const aos tipos de referência, portanto o tipo para Foo sempre será int& .

Como ganhar isso? Basta adicionar especialização para const Foo :

 template<> struct std::tuple_element<0, const Foo> { using type = const int&; }; 

Todos os tipos serão esperados:

 decltype(f1); // const int& decltype(f2); // const int& decltype(f3); // int& decltype(f4); // int& ++f1; //     

A propósito, o mesmo comportamento é verdadeiro para, por exemplo, std::tuple<T&>
- você pode obter uma referência não constante ao elemento interno, mesmo que o próprio objeto seja constante.

Coisas para lembrar


  1. cv-auto ref ” em “ cv-auto ref [a1..an] = expr ” refere-se à variável invisível {e}.
  2. Se o tipo inferido {e} não for referenciado, {e} será inicializado copiando (com cuidado com classes “pesadas”).
  3. Variáveis ​​vinculadas são links "implícitos" (eles se comportam como links, embora decltype retorne um tipo não de referência para eles (a menos que a variável se refira a um link)).
  4. É necessário ter cuidado ao usar tipos de referência para encadernação.

Otimização do valor de retorno (rvo, cópia elision)




Talvez esse fosse um dos recursos mais discutidos do padrão C ++ 17 (pelo menos no meu círculo de amigos). E de fato: o C ++ 11 trouxe a semântica do movimento, que simplificou bastante a transferência do "interno" do objeto e a criação de várias fábricas, e o C ++ 17 em geral, ao que parece, tornou possível não pensar em como devolver o objeto de qualquer método de fábrica , - agora tudo deve ficar sem copiar e, em geral, "logo tudo florescerá em Marte" ...

Mas vamos ser um pouco realistas: otimizar o valor de retorno não é a coisa mais fácil de implementar. Eu recomendo assistir a esta apresentação do cppcon2018: Arthur O'Dwyer “ Otimização do valor de retorno: mais difícil do que parece ”, na qual o autor explica por que é difícil.

Spoiler curto:

Existe um "slot para o valor de retorno". Esse slot é essencialmente apenas um lugar na pilha que é alocado por quem paga e passa para o chamado. Se o código chamado souber exatamente qual objeto único será retornado, ele poderá simplesmente criá-lo imediatamente nesse slot diretamente (desde que o tamanho e o tipo do objeto e do slot sejam os mesmos).

O que se segue disso? Vamos desmontar com exemplos.

Tudo vai ficar bem aqui - o NRVO funcionará, o objeto será construído imediatamente no "slot":

 Base foo1() { Base a; return a; } 

Aqui não é mais possível determinar inequivocamente qual objeto deve ser o resultado; portanto, o construtor de movimentação (c ++ 11) será implicitamente chamado :

 Base foo2(bool c) { Base a,b; if (c) { return a; } return b; } 

Aqui está um pouco mais complicado ... Como o tipo do valor de retorno é diferente do tipo declarado, não é possível chamar implicitamente a move , portanto o construtor de cópia é chamado por padrão. Para impedir que isso aconteça, você precisa chamar explicitamente a move :

 Base foo3(bool c) { Derived a,b; if (c) { return std::move(a); } return std::move(b); } 

Parece que é o mesmo que foo2 , mas o operador ternário é uma coisa muito peculiar ...

 Base foo4(bool c) { Base a, b; return std::move(c ? a : b); } 

Semelhante ao foo4 , mas também um tipo diferente, portanto, move necessário move exatamente:

 Base foo5(bool c) { Derived a, b; return std::move(c ? a : b); } 

Como você pode ver nos exemplos, ainda é preciso pensar em como retornar o significado, mesmo em casos aparentemente triviais ... Existem maneiras de simplificar um pouco a sua vida? Existem: clang há algum tempo agora suporta o diagnóstico da necessidade de chamar explicitamente a move , e há várias propostas ( P1155 , P0527 ) no novo padrão que tornarão a move explícita menos necessária.

Coisas para lembrar


  1. O RVO / NRVO funcionará apenas se:
    • é inequivocamente conhecido qual objeto único deve ser criado no "slot de valor de retorno";
    • O objeto de retorno e os tipos de função são os mesmos.
  2. Se houver ambiguidade no valor de retorno, então:
    • se os tipos do objeto e da função retornados corresponderem, move será chamado implicitamente;
    • caso contrário, você deve chamar explicitamente a movimentação.
  3. Cuidado com o operador ternário: é conciso, mas pode exigir uma movimentação explícita.
  4. É melhor usar compiladores com diagnósticos úteis (ou pelo menos analisadores estáticos).

Conclusão


E ainda assim eu amo C ++;)

Source: https://habr.com/ru/post/pt465181/


All Articles