¿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?
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ó:
si tal ejecución contiene una operación no definida, esta Norma Internacional no impone ningún requisito a la implementación que ejecuta ese programa con esa entrada (ni siquiera con respecto a las operaciones que preceden a la primera operación no definida)
Esto se puede interpretar:
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 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:
- El comportamiento solo está indefinido si se lee 3.
- Los compiladores pueden eliminar el código como muerto si un bloque básico contiene una operación que seguramente no estará definida. Están permitidos (y supongo que sí) en casos que no son un bloque básico pero donde todas las ramas conducen a UB. Este ejemplo no es un candidato a menos que
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".