Contadores de tiempo de compilación C ++, revisitados

28

TL; DR

Antes de intentar leer esta publicación completa, sepa que:

  1. Yo mismo he encontrado una solución al problema presentado , pero todavía estoy ansioso por saber si el análisis es correcto;
  2. He empaquetado la solución en una fameta::counterclase que resuelve algunas peculiaridades restantes. Puedes encontrarlo en github ;
  3. Puedes verlo trabajando en Godbolt .

Cómo empezó todo

Desde que Filip Roséen descubrió / inventó, en 2015, la magia negra que compila los contadores de tiempo están en C ++ , he estado ligeramente obsesionado con el dispositivo, por lo que cuando el CWG decidió que la funcionalidad tenía que desaparecer, me decepcionó, pero todavía tenía la esperanza de que su mente podría cambiarse mostrándoles algunos casos de uso convincentes.

A continuación, un par de años decidí echar un vistazo a lo nuevo, por lo que uberswitch ES podrían ser anidados - un caso de uso interesante, en mi opinión - sólo para descubrir que no funcionaría por más tiempo con las nuevas versiones de los compiladores disponibles, aunque el problema 2118 estaba (y todavía está ) en estado abierto: el código se compilaría, pero el contador no aumentaría.

El problema se ha informado en el sitio web de Roséen y recientemente también en stackoverflow: ¿C ++ admite contadores de tiempo de compilación?

Hace unos días decidí intentar abordar los problemas nuevamente

Quería entender qué había cambiado en los compiladores que hicieron que el C ++, aparentemente válido, no funcionara más. Con ese fin, busqué por toda la red a alguien para hablar de ello, pero fue en vano. Así que comencé a experimentar y llegué a algunas conclusiones, que presento aquí con la esperanza de recibir comentarios de los más conocedores que yo.

A continuación, presento el código original de Roséen para mayor claridad. Para obtener una explicación de cómo funciona, consulte su sitio web :

template<int N>
struct flag {
  friend constexpr int adl_flag (flag<N>);
};

template<int N>
struct writer {
  friend constexpr int adl_flag (flag<N>) {
    return N;
  }

  static constexpr int value = N;
};

template<int N, int = adl_flag (flag<N> {})>
int constexpr reader (int, flag<N>) {
  return N;
}

template<int N>
int constexpr reader (float, flag<N>, int R = reader (0, flag<N-1> {})) {
  return R;
}

int constexpr reader (float, flag<0>) {
  return 0;
}

template<int N = 1>
int constexpr next (int R = writer<reader (0, flag<32> {}) + N>::value) {
  return R;
}

int main () {
  constexpr int a = next ();
  constexpr int b = next ();
  constexpr int c = next ();

  static_assert (a == 1 && b == a+1 && c == b+1, "try again");
}

Con los compiladores g ++ recientes y clang ++, next()siempre devuelve 1. Habiendo experimentado un poco, el problema al menos con g ++ parece ser que una vez que el compilador evalúa los parámetros predeterminados de las plantillas de funciones la primera vez que se llaman las funciones, cualquier llamada posterior a esas funciones no desencadenan una reevaluación de los parámetros predeterminados, por lo tanto, nunca crean instancias de nuevas funciones sino que siempre se refieren a las instancias previamente instanciadas.


Primeras preguntas

  1. ¿Estás de acuerdo con este diagnóstico mío?
  2. En caso afirmativo, ¿es este nuevo comportamiento exigido por la norma? ¿Fue el anterior un error?
  3. Si no, ¿cuál es el problema?

Teniendo en cuenta lo anterior, se me ocurrió una next()solución : marque cada invocación con una identificación única que aumente monotónicamente, para pasar a las calles, de modo que ninguna llamada sea la misma, lo que obliga al compilador a reevaluar todos los argumentos cada vez.

Parece una carga hacer eso, pero al pensar en eso, uno podría usar las macros estándar __LINE__o __COUNTER__similares (siempre que estén disponibles), ocultas en una counter_next()macro similar a una función.

Así que se me ocurrió lo siguiente, que presento en la forma más simplificada que muestra el problema del que hablaré más adelante.

template <int N>
struct slot;

template <int N>
struct slot {
    friend constexpr auto counter(slot<N>);
};

template <>
struct slot<0> {
    friend constexpr auto counter(slot<0>) {
        return 0;
    }
};

template <int N, int I>
struct writer {
    friend constexpr auto counter(slot<N>) {
        return I;
    }

    static constexpr int value = I-1;
};

template <int N, typename = decltype(counter(slot<N>()))>
constexpr int reader(int, slot<N>, int R = counter(slot<N>())) {
    return R;
};

template <int N>
constexpr int reader(float, slot<N>, int R = reader(0, slot<N-1>())) {
    return R;
};

template <int N>
constexpr int next(int R = writer<N, reader(0, slot<N>())+1>::value) {
    return R;
}

int a = next<11>();
int b = next<34>();
int c = next<57>();
int d = next<80>();

Puede observar los resultados de lo anterior en godbolt , que he capturado para los perezosos.

ingrese la descripción de la imagen aquí

Y como puede ver, con trunk g ++ y clang ++ hasta 7.0.0 ¡funciona! , el contador aumenta de 0 a 3 como se esperaba, pero con la versión de clang ++ por encima de 7.0.0 no lo hace .

Para agregar un insulto a la lesión, he logrado hacer que Clang ++ hasta la versión 7.0.0 se bloquee, simplemente agregando un parámetro de "contexto" a la mezcla, de modo que el contador esté realmente vinculado a ese contexto y, como tal, pueda se reiniciará cada vez que se defina un nuevo contexto, lo que abre la posibilidad de usar una cantidad potencialmente infinita de contadores. Con esta variante, clang ++ anterior a la versión 7.0.0 no se bloquea, pero aún así no produce el resultado esperado. Vivir en Godbolt .

Ante la pérdida de alguna pista sobre lo que estaba sucediendo, descubrí el sitio web cppinsights.io , que permite ver cómo y cuándo se instancian las plantillas. Usando ese servicio, lo que creo que está sucediendo es que clang ++ en realidad no define ninguna de las friend constexpr auto counter(slot<N>)funciones cada vez que writer<N, I>se instancia.

Intentar invocar explícitamente counter(slot<N>)cualquier N dado que ya debería haberse instanciado parece dar base a esta hipótesis.

Sin embargo, si trato de crear una instancia explícita writer<N, I>para cualquier cosa Ny Ieso ya debería haberse creado, entonces clang ++ se queja de una redefinición friend constexpr auto counter(slot<N>).

Para probar lo anterior, agregué dos líneas más al código fuente anterior.

int test1 = counter(slot<11>());
int test2 = writer<11,0>::value;

Puedes verlo todo por ti mismo en Godbolt . Captura de pantalla a continuación.

clang ++ cree que ha definido algo que cree que no ha definido

Entonces, parece que clang ++ cree que ha definido algo que cree que no ha definido , lo que hace que la cabeza gire, ¿no?


Segundo lote de preguntas

  1. ¿Es mi solución legal C ++ o me las arreglé para descubrir otro error de g ++?
  2. Si es legal, ¿descubrí algunos errores desagradables de clang ++?
  3. ¿O acabo de adentrarme en el oscuro inframundo de Comportamiento indefinido, para que yo mismo sea el único culpable?

En cualquier caso, daría una calurosa bienvenida a cualquiera que quisiera ayudarme a salir de esta madriguera de conejos, dispensando explicaciones dolorosas si fuera necesario. :RE

Fabio A.
fuente
2
Relacionado: stackoverflow.com/questions/51601439/…
HolyBlackCat
2
Como recuerdo del comité estándar, las personas tienen la clara intención de no permitir construcciones en tiempo de compilación de cualquier tipo, forma o forma que no produzcan exactamente el mismo resultado cada vez que son evaluadas (hipotéticamente). Por lo tanto, podría ser un error del compilador, podría ser un caso "mal formado, no se requiere diagnóstico" o podría ser algo que el estándar omitió. Sin embargo, va en contra del "espíritu de la norma". Lo siento. También me hubiera gustado compilar contadores de tiempo.
bolov
@HolyBlackCat Debo confesar que me resulta muy difícil entender ese código. Parece que podría evitar la necesidad de pasar explícitamente un número monotónicamente creciente como parámetro a la next()función, sin embargo, no puedo entender cómo funciona eso. En cualquier caso, he encontrado una respuesta a mi propio problema, aquí: stackoverflow.com/a/60096865/566849
Fabio A.
@FabioA. Yo tampoco entiendo completamente esa respuesta. Desde que hice esa pregunta, me di cuenta de que no quiero volver a tocar los contadores constexpr.
HolyBlackCat
Si bien este es un pequeño y divertido experimento mental, alguien que realmente usara ese código tendría que esperar que no funcione en futuras versiones de C ++, ¿verdad? En ese sentido, el resultado se define a sí mismo como un error.
Aziuth

Respuestas:

5

Después de una investigación adicional, resulta que existe una modificación menor que se puede realizar a la next()función, lo que hace que el código funcione correctamente en las versiones de clang ++ anteriores a 7.0.0, pero hace que deje de funcionar para todas las demás versiones de clang ++.

Eche un vistazo al siguiente código, tomado de mi solución anterior.

template <int N>
constexpr int next(int R = writer<N, reader(0, slot<N>())+1>::value) {
    return R;
}

Si le prestas atención, lo que literalmente hace es intentar leer el valor asociado slot<N>, agregarle 1 y luego asociar este nuevo valor al mismo slot<N> .

Cuando slot<N>no tiene ningún valor asociado, slot<Y>se recupera el valor asociado con , Ysiendo el índice más alto menor que el Nque slot<Y>tiene un valor asociado.

El problema con el código anterior es que, a pesar de que funciona en g ++, clang ++ (¿con razón, diría?) Hace que devuelva reader(0, slot<N>()) permanentemente lo que devolvió cuando slot<N>no tenía un valor asociado. A su vez, esto significa que todas las ranuras se asocian efectivamente con el valor base 0.

La solución es transformar el código anterior en este:

template <int N>
constexpr int next(int R = writer<N, reader(0, slot<N-1>())+1>::value) {
    return R;
}

Tenga en slot<N>()cuenta que se ha modificado en slot<N-1>(). Tiene sentido: si quiero asociar un valor slot<N>, significa que todavía no hay ningún valor asociado, por lo tanto, no tiene sentido intentar recuperarlo. Además, queremos aumentar un contador, y el valor del contador asociado slot<N>tiene que ser uno más el valor asociado slot<N-1>.

Eureka!

Sin embargo, esto rompe las versiones de clang ++ <= 7.0.0.

Conclusiones

Me parece que la solución original que publiqué tiene un error conceptual, tal que:

  • g ++ tiene peculiaridad / error / relajación que se cancela con el error de mi solución y, a la larga, hace que el código funcione.
  • las versiones de clang ++> 7.0.0 son más estrictas y no les gusta el error en el código original.
  • Las versiones de clang ++ <= 7.0.0 tienen un error que hace que la solución corregida no funcione.

Resumiendo todo eso, el siguiente código funciona en todas las versiones de g ++ y clang ++.

#if !defined(__clang_major__) || __clang_major__ > 7
template <int N>
constexpr int next(int R = writer<N, reader(0, slot<N-1>())+1>::value) {
    return R;
}
#else
template <int N>
constexpr int next(int R = writer<N, reader(0, slot<N>())+1>::value) {
    return R;
}
#endif

El código tal como está también funciona con msvc. El compilador icc no activa SFINAE cuando se usa decltype(counter(slot<N>())), prefiriendo quejarse porque no puede hacerlo deduce the return type of function "counter(slot<N>)"porque sí it has not been defined. Creo que esto es un error , que se puede solucionar haciendo SFINAE en el resultado directo de counter(slot<N>). Esto también funciona en todos los otros compiladores, pero g ++ decide escupir una gran cantidad de advertencias muy molestas que no se pueden desactivar. Entonces, también en este caso, #ifdefpodría venir al rescate.

La prueba está en Godbolt , fotografiado a continuación.

ingrese la descripción de la imagen aquí

Fabio A.
fuente
2
Creo que esta respuesta cierra el tema, pero aún me gustaría saber si estoy en lo cierto en mi análisis, por lo tanto, esperaré antes de aceptar mi propia respuesta como correcta, esperando que alguien más pase y me dé una mejor pista o una confirmación. :)
Fabio A.