c++ language-lawyer undefined-behavior dead-code unreachable-code

c++ - ¿Se puede suponer que las ramas con comportamiento indefinido son inalcanzables y optimizadas como código muerto?



language-lawyer undefined-behavior (8)

¿La existencia de dicha afirmación en un programa dado significa que todo el programa no está definido o que el comportamiento solo se vuelve indefinido una vez que el flujo de control llega a esta afirmación?

Ninguno. La primera condición es demasiado fuerte y la segunda es demasiado débil.

El acceso a objetos a veces se secuencia, pero el estándar describe el comportamiento del programa fuera del tiempo. Danvil ya citado:

si tal ejecución contiene una operación no definida, esta Norma Internacional no exige que la implementación ejecute ese programa con esa entrada (ni siquiera con respecto a las operaciones anteriores a la primera operación no definida)

Esto puede ser interpretado:

Si la ejecución del programa produce un comportamiento indefinido, entonces todo el programa tiene un comportamiento indefinido.

Entonces, una declaración inalcanzable con UB no le da al programa UB. Una declaración alcanzable que (debido a los valores de las entradas) nunca se alcanza, no da al programa UB. Es por eso que tu primera condición es demasiado fuerte.

Ahora, el compilador no puede decir en general qué tiene UB. Por lo tanto, para permitir que el optimizador reordene las declaraciones con potencial de UB que podrían ser pedidas si se definiera su comportamiento, es necesario permitir que UB "retroceda en el tiempo" y se equivoque antes del punto de secuencia anterior (o en C ++ 11 terminología, para que la UB afecte las cosas que están secuenciadas antes de la cosa UB). Por lo tanto, su segunda condición es demasiado débil.

Un ejemplo importante de esto es cuando el optimizador se basa en un aliasing estricto. El objetivo de las reglas de alias estrictas es permitir que el compilador reordene operaciones que no podrían reordenarse válidamente si fuera posible que los punteros en cuestión alias la misma memoria. Por lo tanto, si utiliza punteros aliasing ilegalmente, y UB ocurre, entonces puede afectar fácilmente a una declaración "antes" de la instrucción UB. En lo que respecta a la máquina abstracta, la instrucción UB no se ha ejecutado aún. En lo que respecta al código objeto real, se ha ejecutado parcial o totalmente. Pero el estándar no trata de entrar en detalles sobre lo que significa para el optimizador reordenar las declaraciones, o cuáles son sus implicaciones para UB. Simplemente le da a la licencia de implementación un error tan pronto como le plazca.

Puedes pensar en esto como "UB tiene una máquina del tiempo".

Específicamente para responder tus ejemplos:

  • El comportamiento solo está indefinido si se lee 3.
  • Los compiladores pueden y eliminan el código como muerto si un bloque básico contiene una operación que no está definida. Están permitidos (y supongo que sí) en casos que no son bloques básicos, pero donde todas las ramas conducen a UB. Este ejemplo no es un candidato a menos que se PrintToConsole(3) que PrintToConsole(3) se devuelve. Podría lanzar una excepción o lo que sea.

Un ejemplo similar a su segundo es la opción gcc -fdelete-null-pointer-checks , que puede tomar un código como este (no he comprobado este ejemplo específico, considérelo ilustrativo de la idea general):

void foo(int *p) { if (p) *p = 3; std::cout << *p << ''/n''; }

y cambiarlo a:

*p = 3; std::cout << "3/n";

¿Por qué? Porque si p es nulo, el código tiene UB de todos modos, por lo que el compilador puede suponer que no es nulo y optimizar en consecuencia. El kernel de Linux tropezó con esto ( https://web.nvd.nist.gov/view/vuln/detail?vulnId=CVE-2009-1897 ) esencialmente porque opera en un modo en el que no se supone que desreferenciar un puntero nulo a ser UB, se espera que resulte en una excepción de hardware definida que el kernel puede manejar. Cuando la optimización está habilitada, gcc requiere el uso de -fno-delete-null-pointer-checks para proporcionar esa garantía más allá del estándar.

PS La respuesta práctica a la pregunta "¿cuándo golpea el comportamiento indefinido?" es "10 minutos antes de que planeara irse por el día".

Considere la siguiente declaración:

*((char*)NULL) = 0; //undefined behavior

Claramente invoca un comportamiento indefinido. ¿La existencia de dicha afirmación en un programa dado significa que todo el programa no está definido o que el comportamiento solo se vuelve indefinido una vez que el flujo de control llega a esta afirmación?

¿El programa siguiente estaría bien definido en caso de que el usuario nunca ingrese el número 3 ?

while (true) { int num = ReadNumberFromConsole(); if (num == 3) *((char*)NULL) = 0; //undefined behavior }

¿O es un comportamiento completamente indefinido sin importar lo que ingrese el usuario?

Además, ¿puede el compilador asumir que el comportamiento indefinido nunca se ejecutará en el tiempo de ejecución? Eso permitiría razonar hacia atrás en el tiempo:

int num = ReadNumberFromConsole(); if (num == 3) { PrintToConsole(num); *((char*)NULL) = 0; //undefined behavior }

Aquí, el compilador podría razonar que en caso de que num == 3 siempre invoquemos un comportamiento indefinido. Por lo tanto, este caso debe ser imposible y el número no necesita ser impreso. Toda la sentencia if podría optimizarse. ¿Se permite este tipo de razonamiento retroactivo de acuerdo con el estándar?


El borrador actual de trabajo de C ++ dice en 1.9.4 que

Esta Norma Internacional no impone requisitos sobre el comportamiento de los programas que contienen un comportamiento indefinido.

Basado en esto, diría que un programa que contenga un comportamiento indefinido en cualquier ruta de ejecución puede hacer cualquier cosa en cada momento de su ejecución.

Hay dos artículos realmente buenos sobre el comportamiento indefinido y lo que los compiladores suelen hacer:


El comportamiento indefinido ataca cuando el programa causa un comportamiento indefinido sin importar lo que ocurra a continuación. Sin embargo, usted dio el siguiente ejemplo.

int num = ReadNumberFromConsole(); if (num == 3) { PrintToConsole(num); *((char*)NULL) = 0; //undefined behavior }

A menos que el compilador conozca la definición de PrintToConsole , no puede eliminar if (num == 3) conditional. Supongamos que tiene el LongAndCamelCaseStdio.h sistema LongAndCamelCaseStdio.h con la siguiente declaración de PrintToConsole .

void PrintToConsole(int);

Nada demasiado útil, está bien. Ahora, veamos qué mal (o quizás no tan malo, el comportamiento indefinido podría haber sido peor) que el vendedor es, al verificar la definición real de esta función.

int printf(const char *, ...); void exit(int); void PrintToConsole(int num) { printf("%d/n", num); exit(0); }

El compilador en realidad tiene que suponer que cualquier función arbitraria que el compilador desconozca puede hacer salir o lanzar una excepción (en el caso de C ++). Puedes notar que *((char*)NULL) = 0; no se ejecutará, ya que la ejecución no continuará después de la llamada PrintToConsole .

El comportamiento indefinido PrintToConsole cuando PrintToConsole realmente regresa. El compilador espera que esto no suceda (ya que esto causaría que el programa ejecute un comportamiento indefinido sin importar qué), por lo tanto cualquier cosa puede suceder.

Sin embargo, consideremos otra cosa. Digamos que estamos haciendo una comprobación nula, y usamos la variable después de la verificación nula.

int putchar(int); const char *warning; void lol_null_check(const char *pointer) { if (!pointer) { warning = "pointer is null"; } putchar(*pointer); }

En este caso, es fácil notar que lol_null_check requiere un puntero que no sea NULL. Asignar a la variable global de warning no volátil no es algo que podría salir del programa ni arrojar ninguna excepción. El pointer también es no volátil, por lo que no puede cambiar mágicamente su valor en el medio de la función (si lo hace, es un comportamiento indefinido). Llamar a lol_null_check(NULL) causará un comportamiento indefinido que puede causar que la variable no se asigne (porque en este punto, se conoce el hecho de que el programa ejecuta el comportamiento indefinido).

Sin embargo, el comportamiento indefinido significa que el programa puede hacer cualquier cosa. Por lo tanto, nada impide que el comportamiento indefinido regrese en el tiempo y bloquee su programa antes de que se ejecute la primera línea de int main() . Es un comportamiento indefinido, no tiene sentido. También puede bloquearse después de tipear 3, pero el comportamiento indefinido retrocederá en el tiempo y se bloqueará incluso antes de que escriba 3. Y quién sabe, tal vez el comportamiento indefinido sobrescribirá la memoria RAM de su sistema y hará que su sistema se bloquee 2 semanas después. mientras su programa indefinido no se está ejecutando.


El estándar establece a 1.9 / 4

[Nota: Esta Norma Internacional no impone requisitos sobre el comportamiento de los programas que contienen un comportamiento indefinido. - nota final]

El punto interesante es probablemente lo que significa "contener". Un poco más tarde en 1.9 / 5 dice:

Sin embargo, si dicha ejecución contiene una operación no definida, esta Norma Internacional no exige que la implementación ejecute ese programa con esa entrada (ni siquiera con respecto a las operaciones anteriores a la primera operación no definida)

Aquí menciona específicamente "ejecución ... con esa entrada". Yo interpretaría eso como un comportamiento indefinido en una posible rama que no se ejecuta en este momento no influye en la rama actual de ejecución.

Sin embargo, un problema diferente son las suposiciones basadas en el comportamiento indefinido durante la generación de código. Vea la respuesta de Steve Jessop para más detalles sobre eso.


La palabra "comportamiento" significa que se está haciendo algo. Un estadista que nunca se ejecuta no es "comportamiento".

Una ilustración:

*ptr = 0;

Es ese comportamiento indefinido? Supongamos que estamos 100% seguros de ptr == nullptr al menos una vez durante la ejecución del programa. La respuesta deberia ser si.

¿Qué hay de esto?

if (ptr) *ptr = 0;

¿Eso es indefinido? (Recuerde ptr == nullptr al menos una vez?) Espero que no, de lo contrario no podrá escribir ningún programa útil en absoluto.

Ningún srandardese fue perjudicado al hacer esta respuesta.


Muchos estándares para muchos tipos de cosas gastan mucho esfuerzo en describir cosas que las implementaciones DEBERÍAN o NO DEBEN hacer, usando una nomenclatura similar a la definida en IETF RFC 2119 (aunque no necesariamente citando las definiciones en ese documento). En muchos casos, las descripciones de las cosas que las implementaciones deberían hacer, excepto en los casos en que serían inútiles o impracticables, son más importantes que los requisitos a los que deben ajustarse todas las implementaciones conformes.

Desafortunadamente, los estándares C y C ++ tienden a evitar las descripciones de cosas que, aunque no son 100% requeridas, deberían esperarse de implementaciones de calidad que no documentan comportamientos contrarios. Una sugerencia de que las implementaciones deberían hacer algo podría interpretarse como que implica que aquellas que no lo son son inferiores, y en los casos en que generalmente sería obvio qué comportamientos serían útiles o prácticos, versus poco prácticos e inútiles, en una implementación dada, había poca necesidad percibida de que el estándar interfiera con tales juicios.

Un compilador inteligente podría cumplir con el estándar al tiempo que elimina cualquier código que no tendría ningún efecto excepto cuando el código recibe entradas que inevitablemente causarían un comportamiento indefinido, pero "inteligente" y "tonto" no son antónimos. El hecho de que los autores del Estándar decidieran que podría haber algunos tipos de implementaciones donde comportarse útilmente en una situación dada sería inútil e impráctico no implica ningún juicio sobre si tales comportamientos deberían considerarse prácticos y útiles para otros. Si una implementación puede mantener una garantía de comportamiento sin costo más allá de la pérdida de una oportunidad de poda de "rama muerta", casi cualquier código de usuario de valor que reciba de esa garantía excederá el costo de proporcionarla. La eliminación de la rama muerta puede estar bien en casos en los que no sería necesario renunciar a nada , pero si en una situación dada el código de usuario podría haber manejado casi cualquier comportamiento posible distinto de la eliminación de rama muerta, cualquier código de usuario de esfuerzo tendría que gastarse para evitar UB probablemente excedería el valor obtenido de DBE.


Si el programa llega a una declaración que invoca un comportamiento indefinido, no se imponen requisitos sobre el resultado / comportamiento de ningún programa; no importa si se realizarán "antes" o "después" de que se invoque el comportamiento indefinido.

Su razonamiento sobre los tres fragmentos de código es correcto. En particular, un compilador puede tratar cualquier declaración que invoque incondicionalmente el comportamiento indefinido de la misma forma que GCC trata __builtin_unreachable() : como una sugerencia de optimización de que la declaración es inalcanzable (y por lo tanto, que todas las rutas de código que conducen incondicionalmente a ella también son inalcanzables). Otras optimizaciones similares son, por supuesto, posibles.


Un ejemplo instructivo es

int foo(int x) { int a; if (x) return a; return 0; }

Tanto GCC actual como Clang actual optimizarán esto (en x86) para

xorl %eax,%eax ret

porque deducen que x siempre es cero desde el UB en la ruta de control if (x) . ¡GCC ni siquiera le dará una advertencia de uso de valor no inicializado! (porque el pase que aplica la lógica anterior se ejecuta antes del pase que genera advertencias de valor no inicializado)