Lugares resbaladizos en C ++ 17

imagen

En los últimos años, C ++ ha avanzado a pasos agigantados, y mantenerse al día con todas las sutilezas y complejidades del lenguaje puede ser muy, muy difícil. Un nuevo estándar no está lejos, sin embargo, la introducción de nuevas tendencias no es el proceso más rápido y fácil, por lo tanto, si bien hay un poco de tiempo antes de C ++ 20, sugiero actualizar o descubrir algunos lugares especialmente "resbaladizos" del estándar actual idioma

Hoy les diré por qué si constexpr no es un reemplazo para las macros, cuáles son las "partes internas" del enlace estructurado y sus "escollos" y es cierto que la elisión de copia siempre funciona ahora y puede escribir cualquier devolución sin dudarlo.

Si no tienes miedo de ensuciarte un poco las manos, profundizando en el "interior" de tu lengua, bienvenido a Cat.



si constexpr


Comencemos con el más simple: if constexpr permite descartar la rama de expresión condicional para la que no se cumple la condición deseada, incluso en la etapa de compilación.

Parece que este es un reemplazo para la macro #if para desactivar la lógica "extra"? No En absoluto

En primer lugar, un if tiene propiedades que no están disponibles para macros: en el interior puede contar cualquier expresión constexpr que se pueda constexpr a bool . Bueno, y en segundo lugar, el contenido de la rama descartada debe ser sintáctica y semánticamente correcta.

Debido al segundo requisito, if constexpr no se puede utilizar, por ejemplo, funciones inexistentes (el código dependiente de la plataforma no se puede separar explícitamente de esta manera) o incorrecto desde el punto de vista del lenguaje de construcción (por ejemplo, " void T = 0; ").

¿Cuál es el punto de usar if constexpr ? El punto principal está en las plantillas. Hay una regla especial para ellos: la rama descartada no se instancia cuando se instancia la plantilla. Esto facilita la escritura de código que de alguna manera depende de las propiedades de los tipos de plantilla.

Sin embargo, en las plantillas, no se debe olvidar que el código dentro de las ramas debe ser correcto al menos para alguna variante de instanciación (incluso puramente potencial), por lo tanto, es simplemente static_assert(false) escribir, por ejemplo, static_assert(false) dentro de una de las ramas (es necesario que esto static_assert dependía de algún parámetro dependiente de la plantilla).

Ejemplos:

 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    } } 

Cosas para recordar


  1. El código en todas las ramas debe ser correcto.
  2. Dentro de las plantillas, el contenido de las ramas descartadas no se instancia.
  3. El código dentro de cualquier rama debe ser correcto para al menos una variante puramente potencial de instanciación de la plantilla.

Enlace estructurado




En C ++ 17, apareció un mecanismo bastante conveniente para descomponer varios objetos tipo tupla, lo que le permite vincular de manera conveniente y concisa sus elementos internos a variables con nombre:

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

Por un objeto similar a una tupla, me referiré a un objeto para el que se conoce el número de elementos internos disponibles en el momento de la compilación (de "tupla" - una lista ordenada con un número fijo de elementos (vector)).

Dichas definiciones se incluyen en esta definición como: std::pair , std::tuple , std::array , matrices de la forma " T a[N] ", así como varias estructuras y clases auto escritas.

Parar ... ¿Puedes usar tus propias estructuras en la unión estructural? Spoiler: puedes (aunque a veces tienes que trabajar duro (pero más sobre eso a continuación)).

Como funciona


El trabajo de vinculación estructural merece un artículo separado, pero como estamos hablando específicamente de lugares "resbaladizos", intentaré explicar brevemente cómo funciona todo.

El estándar proporciona la siguiente sintaxis para definir el enlace:

attr (opcional) cv-auto ref-operator (opcional) [ identifier-list ] expresión ;

  • attr - lista de atributos opcionales;
  • cv-auto - auto con posibles modificadores constantes / volátiles;
  • ref-operator referencia: especificador de referencia opcional (& o &&);
  • identifier-list - una lista de nombres de nuevas variables;
  • expression es una expresión que da como resultado un objeto tipo tupla que se usa para la unión (la expresión puede tener la forma " = expr ", " {expr} " o " (expr) ").

Es importante tener en cuenta que el número de nombres en la identifier-list debe coincidir con el número de elementos en el objeto resultante de la expression .

Todo esto le permite escribir construcciones de la forma:

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

Y aquí llegamos al primer lugar "resbaladizo": encontrar una expresión de la forma " auto a = expr; ", Generalmente quiere decir que el tipo" a "se calculará con la expresión" expr ", y espera que en la expresión" const auto& [a,b,c] = expr; "Se hará lo mismo, solo los tipos para" a,b,c "serán los tipos const& elementos correspondientes de" expr "...

La verdad es diferente: el especificador cv-auto ref-operator se usa para calcular el tipo de una variable invisible, a la que se asigna el resultado del cálculo de expr (es decir, el compilador reemplaza " const auto& [a,b,c] = expr " con " const auto& e = expr ").

Por lo tanto, aparece una nueva entidad invisible (en adelante la llamaré {e}), sin embargo, la entidad es muy útil: por ejemplo, puede materializar objetos temporales (por lo tanto, puede conectarlos con seguridad " const auto& [a,b,c] = Foo {}; ").

El segundo lugar resbaladizo se deduce inmediatamente del reemplazo que hace el compilador: si el tipo deducido para {e} no es una referencia, entonces el resultado de expr se copiará en {e}.

¿Qué tipos tendrán las variables en identifier-list ? Para empezar, estos no serán exactamente variables. Sí, se comportan como variables reales, ordinarias, pero solo con la diferencia de que en su interior se refieren a una entidad asociada con ellas, y el decltype de decltype de una variable de "referencia" de este tipo producirá el tipo de entidad a la que se refiere esta variable:

 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 

Los tipos mismos se definen de la siguiente manera:

  1. Si {e} es una matriz ( T a[N] ), entonces el tipo será uno: T, los modificadores cv coincidirán con los de la matriz.
  2. Si {e} es de tipo E y admite la interfaz de tupla, las estructuras se definen:

     std::tuple_size<E> 

     std::tuple_element<i, E> 

    y función:

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

    entonces el tipo de cada variable será el tipo std::tuple_element_t<i, E>
  3. En otros casos, el tipo de la variable corresponderá al tipo de elemento de estructura al que se realiza el enlace.

Entonces, si es muy breve, se toman los siguientes pasos con el enlace estructural:

  1. Cálculo del tipo e inicialización de la entidad invisible {e} en función de los modificadores de tipo expr y cv-ref .
  2. Crea pseudo-variables y únelas a elementos {e}.

Vinculando estructuralmente sus clases / estructuras


El principal obstáculo para vincular sus estructuras es la falta de reflexión en C ++. Incluso el compilador, que, al parecer, debe saber con certeza cómo se organiza esta o aquella estructura en su interior, tiene dificultades: los modificadores de acceso (públicos / privados / protegidos) y la herencia complican mucho las cosas.

Debido a tales dificultades, las restricciones en el uso de sus clases son muy estrictas (al menos por ahora: P1061 , P1096 ):

  1. Todos los campos internos no estáticos de una clase deben ser de la misma clase base y deben estar disponibles en el momento del uso.
  2. O la clase debe implementar "reflexión" (admite la interfaz de 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   ) } 

La implementación de la interfaz de tupla le permite usar cualquiera de sus clases para el enlace, pero se ve un poco engorroso y conlleva otra trampa. Usemos de inmediato un ejemplo:

 //  ,      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; } 

Ahora nos unimos:

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

¿Y es hora de pensar en qué tipos tenemos? (Quien pueda responder de inmediato merece un delicioso cariño).

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

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


¿Por qué sucedió esto? La respuesta se encuentra en la especialización predeterminada 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 no agrega const a los tipos de referencia, por lo que el tipo para Foo siempre será int& .

¿Cómo ganar esto? Solo agrega especialización para const Foo :

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

Entonces se esperarán todos los tipos:

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

Por cierto, el mismo comportamiento es cierto para, por ejemplo, std::tuple<T&>
- puede obtener una referencia no constante al elemento interno, aunque el objeto en sí sea constante.

Cosas para recordar


  1. " cv-auto ref " en " cv-auto ref [a1..an] = expr " se refiere a la variable invisible {e}.
  2. Si no se hace referencia al tipo inferido {e}, {e} se inicializará copiando (cuidadosamente con las clases "pesadas").
  3. Las variables decltype son enlaces "implícitos" (se comportan como enlaces, aunque decltype devuelve un tipo de no referencia (a menos que la variable se refiera a un enlace)).
  4. Se debe tener cuidado al usar tipos de referencia para la unión.

Optimización del valor de retorno (rvo, copia de elisión)




Quizás esta fue una de las características más discutidas del estándar C ++ 17 (al menos en mi círculo de amigos). Y de hecho: C ++ 11 trajo la semántica del movimiento, que simplificó enormemente la transferencia de lo "interno" del objeto y la creación de varias fábricas, y C ++ 17 en general, al parecer, hizo posible no pensar en cómo devolver el objeto de algún método de fábrica , - ahora todo debería ser sin copiar y, en general, "pronto todo florecerá en Marte" ...

Pero seamos un poco realistas: optimizar el valor de retorno no es lo más fácil de implementar. Recomiendo ver esta presentación de cppcon2018: Arthur O'Dwyer " Optimización del valor de retorno: más difícil de lo que parece ", en la que el autor explica por qué es difícil.

Spoiler corto:

Existe una "ranura para el valor de retorno". Esta ranura es esencialmente solo un lugar en la pila que es asignado por quien llama y pasa a la llamada. Si el código llamado sabe exactamente qué objeto individual se devolverá, simplemente puede crearlo inmediatamente en este espacio directamente (siempre que el tamaño y el tipo del objeto y el espacio sean los mismos).

¿Qué se sigue de esto? Vamos a desarmarlo con ejemplos.

Todo estará bien aquí: NRVO funcionará, el objeto se construirá inmediatamente en la "ranura":

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

Aquí ya no es posible determinar inequívocamente qué objeto debería ser el resultado, por lo que el constructor de movimiento (c ++ 11) se llamará implícitamente :

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

Aquí es un poco más complicado ... Dado que el tipo del valor de retorno es diferente del tipo declarado, no puede invocar implícitamente move , por lo que se llama al constructor de copia de forma predeterminada. Para evitar que esto suceda, debe llamar explícitamente a move :

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

Parece que esto es lo mismo que foo2 , pero el operador ternario es algo muy peculiar ...

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

Similar a foo4 , pero también de un tipo diferente, por move necesita move exactamente:

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

Como puede ver en los ejemplos, uno todavía tiene que pensar en cómo devolver el significado incluso en casos aparentemente triviales ... ¿Hay alguna forma de simplificar un poco su vida? Hay: clang desde hace algún tiempo ahora apoya el diagnóstico de la necesidad de llamar explícitamente move , y hay varias propuestas ( P1155 , P0527 ) en el nuevo estándar que harán que el move explícito sea menos necesario.

Cosas para recordar


  1. RVO / NRVO solo funcionará si:
    • se sabe inequívocamente qué objeto único se debe crear en el "espacio de valor de retorno";
    • Los objetos de retorno y los tipos de función son iguales.
  2. Si hay ambigüedad en el valor de retorno, entonces:
    • si los tipos del objeto y la función devueltos coinciden, se llamará a move implícitamente;
    • de lo contrario, debe llamar explícitamente a move.
  3. Precaución con el operador ternario: es conciso, pero puede requerir un movimiento explícito.
  4. Es mejor usar compiladores con diagnósticos útiles (o al menos analizadores estáticos).

Conclusión


Y sin embargo, amo C ++;)

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


All Articles