El caso de los inicializadores automáticos de miembros de datos no estáticos

El caso de los inicializadores automáticos de miembros de datos no estáticos

En este artículo, hablamos sobre los inicializadores automáticos de miembros de datos no estáticos en C++. Todos los fragmentos de código se pueden probar en Compiler Explorer gracias a Matt Godbolt y el equipo de CE. El parche clang para habilitar esta función fue creado por Faisal Vali hace 5 años. ,pero lo he rebasado toscamente sobre Clang Trunk (~ 7.0).

De hecho, la principal motivación de este artículo es poner esta característica en manos de la gente para probar que funciona y que sería una gran adición al estándar.

Tener la capacidad de probar las funciones propuestas en Compiler Explorer es una excelente manera de comprender mejor una función y su caso de esquina. Te animo a que juegues con los fragmentos de código .

Pero lo primero es lo primero.

¿Qué son los inicializadores automáticos de miembros de datos no estáticos (NSDMI)?

Inicializadores de miembros de datos

En C++, puede introducir un valor predeterminado para una variable miembro, que se usará para iniciar una variable si no la inicializa explícitamente, ya sea en una lista de inicializadores de miembros constructores o mediante una inicialización agregada.


int main() {
 struct S {
 int a = 42;
 };
 S s;
 return s.a;
}

Esto se llama Inicializadores de miembros de datos .El inicializador solo se evalúa si el miembro no se inicializa explícitamente. Por ejemplo, en el siguiente ejemplo, main devuelve 0;


int ret = 0;
int main () {
 struct {
 int x = ++ret;
 } x = {0};
 return ret;
}

Inicializadores de miembros de datos estáticos

De manera similar, los miembros estáticos pueden tener un inicializador, aunque las reglas son un poco diferentes. Primero, un inicializador de miembro de datos estáticos siempre se evalúa y reemplaza la definición fuera de clase.

El siguiente código falla porque intentamos definir s::foo dos veces:


struct s {
 static const int foo = 42;
};
int s::foo = 42;

Solo los miembros de datos estáticos que representan un valor literal pueden tener un inicializador de miembro de datos. Esto se debe a que, de lo contrario, ese miembro estático debe tener vinculación (ser direccionable en tiempo de ejecución, por así decirlo) y, como tal, solo debe definirse en todo el programa. De lo contrario, se encontraría con infracciones de ODR. jadeo .

Inicializadores automáticos de miembros de datos estáticos

Miembros de datos estáticos que tienen un inicializador de miembros de datos se puede declarar con auto.


struct s {
 static const auto foo = 42;
};
En este caso, foo se deduce que es de tipo int y funciona exactamente igual que cualquier declaración de una variable con auto :La expresión del lado derecho se evalúa y su tipo determina el tipo de la variable, en este caso, el miembro de datos estáticos.

Inicializadores automáticos de miembros de datos no estáticos

Con todas esas piezas, ahora podemos ver qué es un NSDMI, simplemente un miembro de datos de clase o estructura con un inicializador, cuyo tipo se deduce.


struct s {
 auto foo = 42;
};

Sin embargo, esto no compilará:el estándar lo prohíbe.

El caso para auto NSDM

Entonces, Inicializadores automáticos de miembros de datos no estáticos en realidad no existen ni en C++17 ni en el próximo C++20. Se propuso por última vez en 2008 y no ha generado muchas discusiones desde entonces. ¡Esta publicación de blog intenta abordar eso!

Entonces, ¿debería ser válido el código anterior? Definitivamente creo que sí. El argumento realmente es... ¿por qué no?

¿Siempre automático? No del todo.

Eso puede parecer un argumento pobre, pero los miembros de datos son la única entidad que no se puede declarar con auto .auto puede declarar cualquier tipo de variables en todo tipo de contextos, excepto este. Y ese tipo de excepción desafía las expectativas. Los usuarios pueden intentar usarlos de forma natural, preguntarse por qué no funcionan y luego tendrías que encontrar una buena explicación.

Expresividad de auto

La razón por la que puede querer usar NSDMI automático es la misma que usaría auto en cualquier otro contexto. Creo que el escaparate más fuerte en este momento sería tipo deducción


#include <vector>
struct s {
 auto v1 = std::vector{3, 1, 4, 1, 5};
 std::vector<int> v2 = std::vector{3, 1, 4, 1, 5};
};

make_unique y make_shared también serían buenos candidatos, junto con todos los make_ funciones


#include <memory>
struct s {
 auto ptr = std::make_shared<Foo>();
 std::shared_ptr<Foo> ptr2 = std::make_shared<Foo>();
};

Los literales también pueden ser buenos candidatos, sin embargo, requieren un using namespace que debe evitar hacer en los encabezados. Lo cual es más un problema con los literales y la incapacidad de usar el espacio de nombres en el ámbito de la clase.


#include <chrono>
using namespace std::chrono_literals;
struct doomsday_clock {
 auto to_midnight = 2min;
};

Ya funciona

Como se indica en N2713 - Permitir auto para miembros de datos no estáticos - 2008, casi cualquier cosa que pueda expresarse mediante auto se puede expresar con decltype


struct s {
 decltype(42) foo = 42;
};

De hecho, podemos idear una macro (por favor, no intentes esto en casa)


#define AUTO(var, expr) decltype(expr) var = (expr)
struct s {
 AUTO(foo, 42);
};

Y, si funciona con una sintaxis menos conveniente, ¿por qué no facilitarle la vida a las personas?

Miembros de datos Lambda

Hay una cosa que no se puede lograr con decltype sin embargo:lambda como miembro de datos. De hecho, cada expresión lambda como un tipo único, por lo que decltype([]{}) foo = []{}; no puede funcionar, y debido a eso no se puede lograr lambda como miembro de datos, a menos, por supuesto, recurriendo a algún tipo de borrado de tipo, por ejemplo std::function .

Supongo que no tiene mucho valor el uso de lambdas en lugar de funciones miembro. Excepto que, si las lambdas tienen un grupo de captura, puede almacenar variables específicas de un solo invocable dentro del grupo de captura, lo que le brinda menos datos de los que preocuparse.

Por ejemplo, el siguiente ejemplo captura una variable global (nuevamente, ¡no intente esto en casa!) en el momento de la construcción.

/*
 prints 10 9 8 7 6 5 4 3 2 1
*/
#include <vector>
#include <iostream>
#include <range/v3/view/reverse.hpp>

int counter = 0;
struct object {
 auto id = [counter = ++counter] { return counter;};
};

int main() {
 std::vector<object> v(10);
 for(auto & obj : v | ranges::view::reverse) {
 std::cout << obj.id() << ' ';
 }
}

Entonces... ¿por qué los NSDMI automáticos no están en el estándar?

Aparentemente, casi entraron en 2008, hubo algunas preocupaciones, por lo que se eliminaron y se olvidaron un poco, a pesar de que N2713 propuso agregarlos.

Al analizar una clase, el compilador primero analiza las declaraciones (firmas de funciones, definiciones de variables, clases anidadas, etc.), luego analiza las definiciones en línea, los parámetros predeterminados del método y los inicializadores de miembros de datos.

Eso le permite inicializar un miembro con una expresión que depende de un miembro aún no declarado.


struct s {
 int a = b();
 int b();
};

Sin embargo, si presenta miembros automáticos, las cosas no son tan simples. Tome el siguiente código válido


struct s{
 auto a = b();
 int b() {
 return 42;
 };
} foo;

Aquí, lo que sucede es

  1. El compilador crea un miembro a de auto tipo, en esta etapa la variable a tiene un nombre, pero no un tipo utilizable real.

  2. El compilador crea una función b de tipo int;

  3. El compilador analiza el inicializador de a y a se convierte en un int , sin embargo, b() no se llama.

  4. El compilador analiza la definición de b

  5. El compilador construye foo y llama a b() para inicializar a

En algunos casos, la clase aún no está completa cuando el compilador deduce un tipo de miembro de datos, lo que genera un programa mal formado:


struct s {
 auto a = sizeof(s);
 auto b = 0;
};

Aquí:

  1. El compilador crea un miembro a de auto tipo, en esta etapa la variable a tiene un nombre, pero no un tipo utilizable real.
  2. El compilador crea un miembro b de auto escribir
  3. El compilador analiza el inicializador de a para determinar su tipo
  4. En esta etapa, no se conoce el tamaño de a o b, la clase está "incompleta" y sizeof expresión está mal formada:error: invalid application of 'sizeof' to an incomplete type 's' .

Entonces, hay ciertas cosas que no puede hacer dentro de auto-nsdmi:llamar a sizeof refiriéndose a *this (incluso en decltype), construyendo una instancia de la clase, etc. Todo esto tiene sentido y tendrías el mismo problema con decltype . O simplemente haciendo


struct s {
 s nope;
};

Otro problema es que un auto el miembro de datos no puede depender de otro miembro de datos declarado después de:


struct s {
 auto a = b;
 auto b = 0;
};
int main() {
 return s{}.a;
}

Aquí:

  1. El compilador crea un miembro a de auto tipo, en esta etapa la variable a tiene un nombre, pero no un tipo utilizable real.
  2. El compilador crea un miembro b de auto tipo, en esta etapa la variable b tiene un nombre, pero no un tipo utilizable real.
  3. El compilador analiza el inicializador de a para determinar su tipo. el tipo de b es desconocido y, por lo tanto, el programa está mal formado.

Lo cual, de nuevo, debería sentirse natural para la mayoría de los desarrolladores de c ++. Por desgracia, estas peculiaridades fueron suficientes para que la función nunca se incluyera en el borrador de trabajo.

Compatibilidad binaria

Cambiando struct S { auto x = 0; }; a struct S { auto x = 0.0 ; }; rompe la compatibilidad abi. Si bien esto puede ser un poco confuso, funciona con auto el tipo de devolución tiene el mismo problema. En general, exponer interfaces binarias estables en C++ es un ejercicio complicado que debe evitarse. Esta característica propuesta no exacerba significativamente el problema. Si por alguna razón le preocupa la compatibilidad binaria, evite usar auto en sus interfaces exportadas. Y tal vez evite usar inicializadores de miembros de datos en total.

¿Viene un periódico?

No es algo que planee hacer, ¡solo quería comenzar una discusión nuevamente! El documento original es demasiado antiguo para seguir siendo relevante.

El autor señaló en ese momento:

Recientemente, se señaló en comp.lang.c++.moderated que uno puede obtener el mismo efecto de todos modos, solo que con un código más feo, usando decltype. Debido a eso, el autor cree que la objeción a auto se ha suavizado.

La redacción de la norma cambió significativamente desde entonces. Tanto que me tomó un tiempo encontrar qué previene exactamente el NSDMI automático en el estándar actual, así que veamos algunas palabras.

dcl.spec.auto El tipo de una variable declarada usando auto o decltype(auto) se deduce de su inicializador. Este uso está permitido en una declaración de inicialización ([dcl.init]) de una variable. auto o decltype(auto) aparecerán como uno de los decl-specifiers en decl-specifier-seq y decl-specifier-seq serán seguidos por uno o más declaradores, cada uno de los cuales será seguido por un inicializador no vacío .

Ese primer párrafo hace auto foo = ... válido, y fue fácil de encontrar. Sin embargo, no dice nada acerca de excluir miembros de datos (ni permitir explícitamente miembros de datos estáticos).

básico Una variable se introduce mediante la declaración de una referencia que no sea un miembro de datos no estático o de un objeto. El nombre de la variable, si lo hay, indica la referencia o el objeto.

Estuve atascado durante bastante tiempo antes de pensar en verificar la definición normativa de variable , que selecciona miembros de datos no estáticos. Listo.

Por lo tanto, agregar NSDMI automático al estándar solo requeriría agregar:

dcl.spec.auto El tipo de una variable o miembro de datos declarado usando auto o decltype(auto) se deduce de su inicializador. Este uso está permitido en una declaración de inicialización ([dcl.init]) de una variable.

Pero es posible que el comité también desee especificar exactamente la forma en que interactúan el NSDMI automático y el análisis de clase tardía, que es bastante fácil de explicar en una publicación de blog pero mucho más difícil de redactar.

Agradecimientos

  • Matt Godbolt y el equipo del explorador del compilador por ayudarme a poner esta rama experimental en el explorador del compilador.
  • Faisal Vali, autor del soporte clang inicial.
  • Alexandr Timofeev quien me motivó a escribir este artículo.

Referencias

  • N2713 - Permitir automático para miembros de datos no estáticos - 2008
  • N2712 - Inicializadores de miembros de datos no estáticos
  • Borrador de trabajo de C++