Comportamiento indefinido, en principio

8

Ya sea en C o C ++, creo que este programa ilegal, cuyo comportamiento según el estándar C o C ++ no está definido, es interesante:

#include <stdio.h>

int foo() {
    int a;
    const int b = a;
    a = 555;
    return b;
}

void bar() {
    int x = 123;
    int y = 456;
}

int main() {
    bar();
    const int n1 = foo();
    const int n2 = foo();
    const int n3 = foo();
    printf("%d %d %d\n", n1, n2, n3);
    return 0;
}

Salida en mi máquina (después de la compilación sin optimización):

123 555 555

Creo que este programa ilegal es interesante porque ilustra la mecánica de la pila, porque la razón por la que uno usa C o C ++ (en lugar de, por ejemplo, Java) es para programar cerca del hardware, cerca de la mecánica de la pila y similares.

Sin embargo, en StackOverflow, cuando el código de un interlocutor lee inadvertidamente desde el almacenamiento no inicializado, las respuestas con mayor número de votos siempre citan el estándar C o C ++ (especialmente C ++) en el sentido de que el comportamiento no está definido. Esto es cierto, por supuesto, en lo que respecta al estándar, el comportamiento es indefinido, pero es curioso que las respuestas alternativas intenten, desde una perspectiva de hardware o mecánica de pila, investigar por qué un comportamiento indefinido específico (como el salida anterior) podría haber ocurrido, son raros y tienden a ser ignorados.

Incluso recuerdo una respuesta que sugería que un comportamiento indefinido podría incluir reformatear mi disco duro. Sin embargo, no me preocupé demasiado por eso antes de ejecutar el programa anterior.

Mi pregunta es esta: ¿Por qué es más importante enseñar a los lectores simplemente que el comportamiento no está definido en C o C ++, que comprender el comportamiento indefinido? Quiero decir, si el lector entendiera el comportamiento indefinido, ¿no sería más probable que lo evite?

Resulta que mi educación es en ingeniería eléctrica, y trabajo como ingeniero de construcción de edificios, y la última vez que tuve un trabajo como programador en fue en 1994, por lo que tengo curiosidad por comprender la perspectiva de los usuarios con más convencional, más antecedentes recientes de desarrollo de software.

thb
fuente
3
A veces es realmente difícil entender qué hace realmente su programa hasta que mira el ensamblado producido y ve que el compilador de repente ha optimizado una buena parte del código, todo debido a un pequeño comportamiento indefinido.
Chris
77
El comportamiento indefinido significa que cualquier cosa podría suceder. Si la salida tiene sentido o no, no importa ... Es una suerte aleatoria que el compilador se implemente como se esperaría que fuera ...
Jaa-c
55
La forma en que un compilador elige compilar UB es demasiado específica para ser una pregunta SO útil: depende del compilador particular, el sistema operativo, la arquitectura de la máquina, los niveles de optimización y la versión exacta del compilador que está utilizando. La serie de artículos en blog.llvm.org/2011/05/what-every-c-programmer-should-know.html es una buena descripción de por qué debería evitar UB y algunas de las cosas que pueden salir mal.
Paul Hankin
44
Un compilador diferente, o el mismo compilador bajo diferentes configuraciones, diferentes niveles de optimización, o tal vez incluso en un sistema diferente, podría compilar el código de manera diferente. No puede saber con certeza cuáles serán los resultados. Como depende de la "magia negra" interna del compilador, y posiblemente está influenciada por opciones y otros parámetros externos, lo que posiblemente no sea reproducible, e incluso si lo fuera, no es aconsejable. Si desea obtener información sobre la pila, hay mejores formas de hacerlo, tal vez le sugiera que busque una salida de ensamblaje de códigos válida.
Tommy Andersen
2
El problema con esta pregunta está en cómo define "indefinido" (¡ja!). Si sabe lo que va a hacer el compilador, no está indefinido : está definido por la implementación (si el estándar ISO C no le da permiso explícito a la implementación para definirlo, entonces está definido por la implementación y ahora está usando GNU C o lo que sea en lugar de ISO C). No tiene sentido hablar de "comprender" la verdadera UB; si puede entenderse constantemente, no lo es.
Leushenko

Respuestas:

5

El análisis de valor de Frama-C, un analizador estático cuyo objetivo es encontrar todos los comportamientos indefinidos en un programa en C, considera que la asignación const int b = a;está bien. Esta es una decisión de diseño deliberada para permitir memcpy()(típicamente implementado como un bucle sobre unsigned charelementos de una matriz virtual, y que el estándar C posiblemente permita volver a implementar como tal) para copiar un struct(que puede tener relleno y miembros no inicializados) en otro.

La "excepción" es solo para lvalue = lvalue;tareas sin una conversión intermedia, es decir, para una tarea que equivale a una copia de una porción de memoria para una ubicación de memoria a otra.

Yo (como uno de los autores del análisis de valor de Frama-C) discutí esto con Xavier Leroy en un momento en que él mismo se preguntaba sobre la definición para elegir en el compilador de C verificado CompCert, por lo que puede haber terminado usando la misma definición. En mi opinión, es más limpio que lo que el estándar C intenta hacer con valores indeterminados que pueden ser representaciones de trampas, y el tipo unsigned charque se garantiza que no tiene representaciones de trampas, pero CompCert y Frama-C asumen objetivos relativamente no exóticos, y tal vez el comité de estandarización estaba tratando de acomodar plataformas donde la lectura de un no inicializado intrealmente puede abortar el programa.

Volviendo b, o que pasa n1, n2o n3que printf, al final, al menos, puede ser considerado como un comportamiento indefinido, porque la copia de un trozo sin inicializar la memoria no por lo que es inicializado. Con una versión más antigua de Frama-C:

$ frama-c -val t.c

t.c:19:… accessing uninitialized left-value: assert \initialized(&n1);

Y en una versión más antigua de CompCert, después de modificaciones menores para que el programa sea aceptable para él:

$ ccomp -interp t.c
Time 33: in function foo, expression <loc> = <undef>
ERROR: Undefined behavior
Complicado ver bio
fuente
8

El comportamiento indefinido en última instancia significa que el comportamiento no es determinista. Los programadores que no son conscientes de que están escribiendo código no determinista son simplemente malos programadores ignorantes. Este sitio tiene como objetivo hacer que los programadores sean mejores (y menos ignorantes).

Escribir un programa correcto frente a un comportamiento no determinista no es imposible. Sin embargo, es un entorno de programación especializado y requiere un tipo diferente de disciplina de programación.

Incluso en su ejemplo, si el programa recibe una señal externa elevada, los valores en la "pila" pueden cambiar de tal manera que no obtenga los valores esperados. Además, si la máquina tiene valores de trampa, la lectura de valores aleatorios puede causar que ocurra algo extraño.

jxh
fuente
44
@jxh No estoy seguro de que lo no determinista sea ​​correcto. Un programa podría ser indefinido pero completamente repetible en una plataforma determinada, ¿verdad?
cuant
3
@Arman: Puede o no ser repetible en una plataforma determinada, ese es el punto.
jxh
1
@Giorgio: El otro punto es que el comportamiento indefinido no necesita ser determinista, incluso para la misma plataforma e implementación.
jxh
1
C y C ++ usan dos términos diferentes: comportamiento indefinido y comportamiento no especificado. También hay una secuencia indeterminada. Y la distinción es importante. Es posible, aunque difícil, escribir un programa correcto en presencia de un comportamiento no especificado. Pero ninguna cantidad de codificación cuidadosa puede garantizar la corrección en presencia de un comportamiento indefinido. El comportamiento indefinido elimina el significado semántico de todo su programa. Por otro lado, la plataforma puede definir el comportamiento que el lenguaje deja sin definir.
Ben Voigt
1
@jxh: los sistemas tolerantes a fallas son bastante interesantes. Pero no son tolerantes al comportamiento indefinido. Las copias que se ejecutan en bloque y que tienen un comportamiento indefinido pueden tomar una decisión equivocada, y la votación no ayudará entonces.
Ben Voigt
6

¿Por qué es más importante enseñar a los lectores simplemente que el comportamiento no está definido en C o C ++, que comprender el comportamiento indefinido?

Debido a que el comportamiento específico puede no ser repetible, incluso de ejecución en ejecución sin reconstrucción.

Perseguir exactamente lo que sucedió puede ser un ejercicio académico útil para comprender mejor las peculiaridades de su plataforma en particular, pero desde una perspectiva de codificación , la única lección relevante es "no haga eso". Una expresión como a++ * a++es un error de codificación, punto final. Eso es realmente todo lo que alguien necesita saber.

John Bode
fuente
5

"Comportamiento indefinido" es la abreviatura de "Este comportamiento no es determinista; no solo probablemente se comportará de manera diferente en diferentes compiladores o plataformas de hardware, sino que incluso puede comportarse de manera diferente en diferentes versiones del mismo compilador".

La mayoría de los programadores considerarían esto como una característica indeseable, especialmente porque C y C ++ son lenguajes basados ​​en estándares ; es decir, los usa, en parte, porque la especificación del lenguaje garantiza ciertas formas sobre cómo se comportará el lenguaje, si está utilizando un compilador compatible con los estándares.

Como con la mayoría de las cosas en la programación, debe sopesar las ventajas y desventajas. Si el beneficio de alguna operación que es UB excede la dificultad de lograr que se comporte de manera estable y independiente de la plataforma, entonces, por todos los medios, use el comportamiento indefinido. La mayoría de los programadores pensarán que no vale la pena, la mayoría de las veces.

El remedio para cualquier comportamiento indefinido es examinar el comportamiento que realmente obtienes, dada una plataforma y un compilador en particular. Ese tipo de examen no es uno que un programador experto pueda explorar por usted en un entorno de preguntas y respuestas.

Robert Harvey
fuente
+1 Como @aschepler ha explicado mejor que yo, los detalles específicos del comportamiento indefinido tienden a ser de interés durante la depuración. Si mi unidad prueba los valores predeterminados y entiendo la mecánica de administración de memoria que produce los valores predeterminados, entonces puedo depurar mi programa más rápido. Por supuesto que tiene razón: ¡es difícil pensar en un caso en el que uno invoque a propósito a UB en el código terminado!
2014
1
Se pierde "con diferentes opciones de compilación". Siempre es divertido cuando las versiones de desarrollo / prueba / lanzamiento se comportan de manera diferente.
Henk Holterman
1
O incluso "puede producir resultados diferentes en ejecuciones consecutivas del mismo binario, como resultado de una sola compilación".
Vatine
Comportamiento indefinido a veces tenía la intención de significar eso, y a veces tenía la intención de significar "Este comportamiento de acción debería funcionar de manera idéntica en todas las implementaciones para plataformas que conocemos, pero se le permitiría comportarse de manera diferente en plataformas donde eso sería problemático; no hay necesidad de obligar el comportamiento normal en plataformas comunes porque los escritores de compiladores que no están siendo deliberadamente obtusos procesarán las cosas de esa manera, ya sea que el Estándar lo requiera o no ". Un ejemplo de esto último sería (-1)<<1qué C89 definió como -2 en plataformas que usan dos complementos sin relleno ...
supercat
... tipos enteros, pero C99 considera Comportamiento indefinido sin dar ninguna razón para el cambio. Si uno interpreta el significado previsto como anteriormente, entonces no sería un cambio radical, excepto en plataformas donde el comportamiento de C89 no era práctico, pero de todos modos se basaba en algún código.
supercat
1

Si la documentación de un compilador en particular dice qué hará cuando el código haga algo que el estándar considera "Comportamiento indefinido", entonces el código que se basa en ese comportamiento funcionará correctamente cuando se compila con ese compilador , pero puede comportarse de manera arbitraria cuando compilado usando algún otro compilador cuya documentación no especifica el comportamiento.

Si la documentación para un compilador no especifica cómo manejará un "comportamiento indefinido" en particular, el hecho de que el comportamiento de un programa parezca obedecer ciertas reglas no dice nada acerca de cómo se comportarán los programas similares. Cualquier variedad de factores puede hacer que un compilador emita código que maneja situaciones inesperadas de manera diferente, a veces de una manera aparentemente extraña.

Considere, por ejemplo, en una máquina donde inthay un número entero de 32 bits:

int undef_behavior_example(uint16_t size1, uint16_t size2)
{
  int flag = 0;
  if ((uint32_t)size1 * size2 > 2147483647u)
    flag += 1;
  if (((size1*size2) & 127) != 0) // Test whether product is a multiple of 128
    flag += 2;
  return flag;
}

Si size1ysize2ambos eran iguales a 46341 (su producto es 2147488281) uno podría esperar que la función devuelva 3, pero un compilador podría omitir legítimamente la primera prueba por completo; o el producto sería lo suficientemente pequeño como para que la condición fuera falsa, o la próxima multiplicación se desbordaría y aliviaría al compilador de cualquier requisito de hacer o haber hecho algo. Si bien este comportamiento puede parecer extraño, algunos autores de compiladores parecen estar orgullosos de las habilidades de sus compiladores para eliminar tales pruebas "innecesarias". Algunas personas pueden esperar que un desbordamiento en la segunda multiplicación, en el peor de los casos, haga que todos los bits de ese producto en particular se corrompan arbitrariamente; de hecho, sin embargo,

Super gato
fuente
¿No se haría la multiplicación módulo UINT16_MAX?
curioso
@curiousguy: si intes un entero de 32 bits, los valores de tipo uint16_tse promocionarán intantes de cualquier cálculo que los involucre. Una regla que generalmente estaría bien si las implementaciones solo trataran la aritmética con signo como diferente de sin signo en los casos en que tendrían comportamientos definidos diferentes.
supercat
Creo que cualquier operando de tipo sin signo provocó que la operación no tuviera signo.
curioso
@curiousguy: algunos compiladores trabajaron de esa manera en los días previos al Estándar, pero el Estándar especifica que los tipos sin signo que se clasifican a continuación unsignedy tienen un rango de valores que se ajustarán completamente dentro de eso int, son promovidos a firmados int.
supercat