Considere la siguiente declaración:
*((char*)NULL) = 0; //undefined behavior
Claramente invoca un comportamiento indefinido. ¿La existencia de tal declaració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 declaración?
¿El siguiente programa 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 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 num == 3
que siempre invocaremos un comportamiento indefinido. Por lo tanto, este caso debe ser imposible y no es necesario imprimir el número. La if
declaración completa podría optimizarse. ¿Se permite este tipo de razonamiento al revés según el estándar?
const int i = 0; if (i) 5/i;
.PrintToConsole
no llama,std::exit
por lo que tiene que realizar la llamada.Respuestas:
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 citó:
Esto se puede interpretar:
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 le da al programa UB. Es por eso que su primera condición es demasiado fuerte.
Ahora bien, el compilador no puede decir en general qué tiene UB. Por lo tanto, para permitir que el optimizador reordene las declaraciones con UB potencial que podrían reordenarse si se definiera su comportamiento, es necesario permitir que UB "retroceda en el tiempo" y salga mal antes del punto de secuencia anterior (o en C ++ 11 terminología, para que la UB afecte a las cosas que están secuenciadas antes de la 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 alias estricto. El objetivo de las reglas estrictas de alias es permitir al compilador reordenar operaciones que no podrían reordenarse válidamente si fuera posible que los punteros en cuestión alias la misma memoria. Entonces, si usa punteros de aliasing ilegalmente, y UB ocurre, entonces puede afectar fácilmente una instrucción "antes" de la instrucción UB. En lo que respecta a la máquina abstracta, la instrucción UB aún no se ha ejecutado. En lo que respecta al código de objeto real, se ha ejecutado total o parcialmente. Pero el estándar no intenta entrar en detalles sobre lo que significa para el optimizador reordenar declaraciones, o cuáles son las implicaciones de eso para UB. Simplemente le da la licencia de implementación para que funcione mal tan pronto como le plazca.
Puedes pensar en esto como, "UB tiene una máquina del tiempo".
Específicamente para responder a sus ejemplos:
PrintToConsole(3)
se sepa de alguna manera que esté seguro de regresar. Podría lanzar una excepción o lo que sea.Un ejemplo similar al segundo es la opción gcc
-fdelete-null-pointer-checks
, que puede tomar un código como este (no he verificado este ejemplo específico, considérelo ilustrativo de la idea general):void foo(int *p) { if (p) *p = 3; std::cout << *p << '\n'; }
y cámbielo 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 asumir 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 sea UB, se espera que resulte en una excepción de hardware definida que el kernel pueda 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.PD: La respuesta práctica a la pregunta "¿cuándo aparece el comportamiento indefinido?" es "10 minutos antes de que planeara partir por el día".
fuente
void can_add(int x) { if (x + 100 < x) complain(); }
se puede optimizar por completo, porque six+100
no se desborda, no pasa nada, y six+100
se desborda, es UB según el estándar, por lo que no puede pasar nada .3
si quisiera, y empacar en casa por el día tan pronto como lo viera entrante.Los estados estándar en 1.9 / 4
El punto interesante es probablemente lo que significa "contener". Un poco más tarde en 1.9 / 5 dice:
Aquí menciona específicamente "ejecución ... con esa entrada". Lo interpretaría como que un comportamiento indefinido en una posible rama que no se ejecuta en este momento no influye en la rama de ejecución actual.
Sin embargo, un problema diferente son las suposiciones basadas en un comportamiento indefinido durante la generación de código. Vea la respuesta de Steve Jessop para obtener más detalles al respecto.
fuente
Un ejemplo instructivo es
int foo(int x) { int a; if (x) return a; return 0; }
Tanto el GCC actual como el Clang actual optimizarán esto (en x86) para
porque deducen que
x
siempre es cero de la UB en laif (x)
ruta de control. ¡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)fuente
a
incluso si en todas las circunstancias uninitializeda
pasaría a la función que la función nunca haría nada con ella)?El borrador de trabajo actual de C ++ dice en 1.9.4 que
En base a esto, diría que un programa que contiene 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 suelen hacer los compiladores:
fuente
int f(int x) { if (x > 0) return 100/x; else return 100; }
ciertamente nunca invoca un comportamiento indefinido, aunque,100/0
por supuesto, no está definido.printf("Hello, World"); *((char*)NULL) = 0
no se garantiza que imprima nada. Esto ayuda a la optimización, porque el compilador puede reordenar libremente las operaciones (sujeto a restricciones de dependencia, por supuesto) que sabe que ocurrirán eventualmente, sin tener que tomar en cuenta un comportamiento indefinido.int x,y; std::cin >> x >> y; std::cout << (x+y);
está permitido decir que "1 + 1 = 17", solo porque hay algunas entradas donde sex+y
desborda (que es UB ya queint
es un tipo con signo)?La palabra "comportamiento" significa que se está haciendo algo . Un estadista que nunca se ejecuta no es "comportamiento".
Una ilustración:
*ptr = 0;
¿Es un comportamiento indefinido? Supongamos que estamos 100% seguros
ptr == nullptr
al menos una vez durante la ejecución del programa. La respuesta deberia ser si.¿Qué pasa con esto?
if (ptr) *ptr = 0;
¿Eso es indefinido? (¿Se acuerda
ptr == nullptr
al menos una vez?) Espero que no, de lo contrario no podrá escribir ningún programa útil.Ningún srandardese resultó dañado en la elaboración de esta respuesta.
fuente
El comportamiento indefinido aparece cuando el programa causa un comportamiento indefinido sin importar lo que suceda a continuación. Sin embargo, 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 eliminarif (num == 3)
condicional. Supongamos que tiene unLongAndCamelCaseStdio.h
encabezado del sistema con la siguiente declaración dePrintToConsole
.void PrintToConsole(int);
Nada demasiado útil, de acuerdo. Ahora, veamos qué tan malvado (o quizás no tan malvado, el comportamiento indefinido podría haber sido peor) el proveedor, 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 asumir que cualquier función arbitraria que el compilador no sepa qué hace puede salir o lanzar una excepción (en el caso de C ++). Puede notar que
*((char*)NULL) = 0;
no se ejecutará, ya que la ejecución no continuará después de laPrintToConsole
llamada.El comportamiento indefinido ataca cuando
PrintToConsole
realmente regresa. El compilador espera que esto no suceda (ya que esto haría que el programa ejecute un comportamiento indefinido pase lo que pase), por lo tanto, puede pasar cualquier cosa.Sin embargo, consideremos algo más. Digamos que estamos haciendo una verificación nula y usamos la variable después de una 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 no NULL. La asignación a lawarning
variable global no volátil no es algo que pueda salir del programa o generar alguna excepción. Elpointer
también es no volátil, por lo que no puede cambiar mágicamente su valor en medio de la función (si lo hace, es un comportamiento indefinido). Llamarlol_null_check(NULL)
provocará un comportamiento indefinido que puede hacer que la variable no sea asignada (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 retroceda en el tiempo y bloquee su programa antes de que se
int main()
ejecute la primera línea . Es un comportamiento indefinido, no tiene por qué tener sentido. También puede fallar después de escribir 3, pero el comportamiento indefinido retrocederá en el tiempo y fallará incluso antes de que escriba 3. Y quién sabe, tal vez un comportamiento indefinido sobrescriba la RAM de su sistema y haga que su sistema se bloquee 2 semanas después. mientras su programa indefinido no se esté ejecutando.fuente
PrintToConsole
es mi intento de insertar un efecto secundario externo al programa que es visible incluso después de fallas y está fuertemente secuenciado. Quería crear una situación en la que podamos saber con certeza si esta declaración se optimizó. Pero tiene razón en que puede que nunca vuelva. Su ejemplo de escritura en un global podría estar sujeto a otras optimizaciones que no están relacionadas con UB. Por ejemplo, se puede eliminar un global no utilizado. ¿Tiene una idea para crear un efecto secundario externo de una manera que garantice que devuelva el control?volatile
variable podría activar legítimamente una operación de E / S que a su vez podría interrumpir inmediatamente el hilo actual; el manejador de interrupciones podría entonces matar el hilo antes de que tenga la oportunidad de realizar cualquier otra cosa. No veo ninguna justificación por la cual el compilador pueda impulsar un comportamiento indefinido antes de ese punto.Si el programa llega a una declaración que invoca un comportamiento indefinido, no se imponen requisitos en ninguno de los resultados / comportamiento del programa; no importa si tendrán lugar "antes" o "después" de que se invoca un comportamiento indefinido.
Su razonamiento sobre los tres fragmentos de código es correcto. En particular, un compilador puede tratar cualquier declaración que invoca incondicionalmente un comportamiento indefinido de la forma en 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). Por supuesto, son posibles otras optimizaciones similares.fuente
__builtin_unreachable()
empezaron a tener efectos que procedían tanto hacia atrás como hacia adelante en el tiempo? Dado algo comoextern volatile uint32_t RESET_TRIGGER; void RESET(void) { RESET_TRIGGER = 0xAA55; __memorybarrier(); __builtin_unreachable(); }
, podría verbuiltin_unreachable()
que es bueno que el compilador sepa que puede omitir lareturn
instrucción, pero eso sería bastante diferente de decir que el código anterior podría omitirse.__builtin_unreachable
se alcanza. Este programa está definido.restrict
puntero en vivo , se escriban usando ununsigned char*
.Muchos estándares para muchos tipos de cosas gastan mucho esfuerzo en describir cosas que las implementaciones DEBEN 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 deberían hacer las implementaciones, excepto en los casos en que serían inútiles o poco prácticas, 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 se requieren al 100%, deberían esperarse de implementaciones de calidad que no documentan el comportamiento contrario. Una sugerencia de que las implementaciones deberían hacer algo podría verse como una implicación de que aquellas que no lo hacen son inferiores, y en los casos en los que generalmente sería obvio qué comportamientos serían útiles o prácticos, en lugar de imprácticos e inútiles, en una implementación dada, había poca necesidad percibida de que la Norma interfiera con tales juicios.
Un compilador inteligente podría ajustarse al Estándar y eliminar 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 en las que comportarse de manera útil en una situación dada sería inútil y poco práctico no implica ningún juicio sobre si tales comportamientos deben considerarse prácticos y útiles para otros. Si una implementación pudiera mantener una garantía de comportamiento sin costo alguno más allá de la pérdida de una oportunidad de poda de "rama muerta", casi cualquier valor que el código de usuario pudiera recibir de esa garantía excedería el costo de proporcionarlo. La eliminación de la rama muerta puede estar bien en los casos en que no lo haría ', pero si en una situación dada el código de usuario podría haber manejado casi cualquier comportamiento posible que no sea la eliminación de ramas muertas, cualquier esfuerzo que el código de usuario tendría que gastar para evitar UB probablemente excedería el valor logrado de DBE.
fuente
x*y < z
cuándox*y
no se desborda, y en caso de desbordamiento produce 0 o 1 de manera arbitraria pero sin efectos secundarios, no hay ninguna razón en la mayoría de las plataformas por la que cumplir con el segundo y tercer requisitos debería ser más costoso que cumplir con el primero, pero cualquier forma de escribir la expresión para garantizar el comportamiento definido por el Estándar en todos los casos agregaría un costo significativo en algunos casos. Escribir la expresión como(int64_t)x*y < z
podría más que cuadriplicar el costo de cálculo ...(int)((unsigned)x*y) < z
evitaría que un compilador empleara lo que de otra manera podrían haber sido sustituciones algebraicas útiles (por ejemplo, si sabe quex
yz
son iguales y positivos, podría simplificar la expresión original ay<0
, pero la versión que usa unsigned obligaría al compilador a realizar la multiplicación). Si el compilador puede garantizar que aunque el Estándar no lo exija, mantendrá el requisito de "rendimiento 0 o 1 sin efectos secundarios", el código de usuario podría brindarle al compilador oportunidades de optimización que de otro modo no podría obtener.x*y
emisión de un valor normal en caso de desbordamiento, pero cualquier valor. UB configurable en C / C ++ me parece importante.