Las especificaciones C \ C ++ dejan fuera una gran cantidad de comportamientos abiertos para que los compiladores los implementen a su manera. Hay una serie de preguntas que siempre se siguen haciendo aquí sobre lo mismo y tenemos algunas publicaciones excelentes al respecto:
- https://stackoverflow.com/questions/367633/what-are-all-the-common-undefined-behaviour-that-ac-programmer-should-know-abo
- https://stackoverflow.com/questions/4105120/what-is-undefined-behavior
- https://stackoverflow.com/questions/4176328/undefined-behavior-and-sequence-points
Mi pregunta no es sobre qué es el comportamiento indefinido o si es realmente malo. Conozco los peligros y la mayoría de las citas de comportamiento indefinido relevantes del estándar, por lo que debe abstenerse de publicar respuestas sobre lo malo que es. Esta pregunta es acerca de la filosofía detrás de dejar muchos comportamientos abiertos para la implementación del compilador.
Leí una excelente publicación de blog que dice que el rendimiento es la razón principal. Me preguntaba si el rendimiento es el único criterio para permitirlo, o ¿hay otros factores que influyan en la decisión de dejar las cosas abiertas para la implementación del compilador?
Si tiene algún ejemplo para citar sobre cómo un comportamiento indefinido en particular proporciona suficiente espacio para que el compilador lo optimice, por favor enlistelos. Si conoce otros factores además del rendimiento, respalde su respuesta con suficiente detalle.
Si no comprende la pregunta o no tiene suficientes evidencias / fuentes para respaldar su respuesta, no publique respuestas especulativas generales.
fuente
Respuestas:
Primero, notaré que aunque solo mencione "C" aquí, lo mismo se aplica igualmente a C ++ también.
El comentario que mencionaba a Godel fue en parte (pero solo en parte) acertado.
Cuando te pones a ello, el comportamiento indefinido en los estándares C es en gran medida solo señalar el límite entre lo que el estándar intenta definir y lo que no.
Los teoremas de Godel (hay dos) básicamente dicen que es imposible definir un sistema matemático que pueda probarse (por sus propias reglas) que sea completo y consistente. Puede hacer sus reglas para que estén completas (el caso con el que trató fueron las reglas "normales" para los números naturales), o puede hacer posible demostrar su consistencia, pero no puede tener ambas.
En el caso de algo como C, eso no se aplica directamente; en su mayor parte, la "capacidad de prueba" de la integridad o consistencia del sistema no es una alta prioridad para la mayoría de los diseñadores de idiomas. Al mismo tiempo, sí, probablemente fueron influenciados (al menos en cierto grado) al saber que es probablemente imposible definir un sistema "perfecto", uno que sea demostrablemente completo y consistente. Saber que tal cosa es imposible puede haber hecho un poco más fácil dar un paso atrás, respirar un poco y decidir sobre los límites de lo que tratarían de definir.
A riesgo de (una vez más) ser acusado de arrogancia, caracterizaría el estándar C como gobernado (en parte) por dos ideas básicas:
El primero significa que si alguien define una nueva CPU, debería ser posible proporcionar una implementación buena, sólida y utilizable de C para eso, siempre y cuando el diseño esté al menos razonablemente cerca de algunas pautas simples, básicamente, si sigue algo en el orden general del modelo de Von Neumann y proporciona al menos una cantidad mínima de memoria razonable, que debería ser suficiente para permitir una implementación en C. Para una implementación "alojada" (una que se ejecuta en un sistema operativo), debe admitir alguna noción que corresponda razonablemente a los archivos, y tener un conjunto de caracteres con un cierto conjunto mínimo de caracteres (se requieren 91).
El segundo significa que debería ser posible escribir código que manipule el hardware directamente, por lo que puede escribir cosas como cargadores de arranque, sistemas operativos, software integrado que se ejecuta sin ningún sistema operativo, etc. En última instancia, hay algunos límites a este respecto, por lo que casi cualquier Es probable que el sistema operativo práctico, el gestor de arranque, etc., contenga al menos un poco de código escrito en lenguaje ensamblador. Del mismo modo, es probable que incluso un pequeño sistema integrado incluya al menos algún tipo de rutinas de biblioteca preescritas para dar acceso a los dispositivos en el sistema host. Aunque es difícil definir un límite preciso, la intención es que la dependencia de dicho código se mantenga al mínimo.
El comportamiento indefinido en el lenguaje está impulsado en gran medida por la intención del lenguaje de admitir estas capacidades. Por ejemplo, el idioma le permite convertir un entero arbitrario en un puntero y acceder a lo que sea que esté en esa dirección. El estándar no intenta decir qué sucederá cuando lo haga (por ejemplo, incluso leer desde algunas direcciones puede tener efectos visibles desde el exterior). Al mismo tiempo, no intenta evitar que haga tales cosas, porque necesita algunos tipos de software que se supone que puede escribir en C.
Existe también un comportamiento indefinido impulsado por otros elementos de diseño. Por ejemplo, otra intención de C es admitir una compilación separada. Esto significa (por ejemplo) que se pretende que pueda "vincular" piezas utilizando un vinculador que sigue aproximadamente lo que la mayoría de nosotros vemos como el modelo habitual de un vinculador. En particular, debería ser posible combinar módulos compilados por separado en un programa completo sin el conocimiento de la semántica del lenguaje.
Hay otro tipo de comportamiento indefinido (que es mucho más común en C ++ que C), que está presente simplemente debido a los límites en la tecnología del compilador: cosas que básicamente sabemos que son errores, y probablemente le gustaría que el compilador diagnostique como errores, pero dados los límites actuales en la tecnología de compilación, es dudoso que puedan diagnosticarse en todas las circunstancias. Muchos de estos están impulsados por otros requisitos, como la compilación por separado, por lo que se trata principalmente de equilibrar los requisitos en conflicto, en cuyo caso el comité generalmente ha optado por apoyar mayores capacidades, incluso si eso significa la falta de diagnóstico de algunos posibles problemas, en lugar de limitar las capacidades para garantizar que se diagnostiquen todos los posibles problemas.
Estas diferencias en la intención impulsan la mayoría de las diferencias entre C y algo como Java o los sistemas basados en CLI de Microsoft. Estos últimos están bastante explícitamente limitados a trabajar con un conjunto de hardware mucho más limitado, o requieren que el software emule el hardware más específico al que se dirigen. También tienen la intención específica de evitar cualquier manipulación directa de hardware, en su lugar requieren que use algo como JNI o P / Invoke (y código escrito en algo como C) para incluso hacer tal intento.
Volviendo a los teoremas de Godel por un momento, podemos trazar una especie de paralelo: Java y CLI han optado por la alternativa "internamente consistente", mientras que C ha optado por la alternativa "completa". Por supuesto, esto es una analogía muy áspera - Dudo que alguien está intentando una prueba formal de cualquiera de consistencia interna o la integridad en ambos casos. No obstante, la noción general encaja bastante estrechamente con las elecciones que han tomado.
fuente
La justificación de C explica
También es importante el beneficio para los programas, no solo el beneficio para las implementaciones. Un programa que depende de un comportamiento indefinido aún puede ser conforme , si es aceptado por una implementación conforme. La existencia de un comportamiento indefinido permite que un programa use características no portátiles explícitamente marcadas como tales ("comportamiento indefinido"), sin volverse no conforme. La justificación señala:
Y en 1.7 se nota
Por lo tanto, este pequeño programa sucio que funciona perfectamente bien en GCC todavía se está conformando .
fuente
La cuestión de la velocidad es especialmente un problema en comparación con C. Si C ++ hiciera algunas cosas que podrían ser sensatas, como inicializar grandes matrices de tipos primitivos, perdería una tonelada de puntos de referencia con el código C. Entonces, C ++ inicializa sus propios tipos de datos, pero deja los tipos C tal como estaban.
Otro comportamiento indefinido solo refleja la realidad. Un ejemplo es el desplazamiento de bits con un recuento mayor que el tipo. Eso realmente difiere entre las generaciones de hardware de la misma familia. Si tiene una aplicación de 16 bits, exactamente el mismo binario dará resultados diferentes en un 80286 y un 80386. ¡Entonces el estándar del lenguaje dice que no lo sabemos!
Algunas cosas simplemente se mantienen como estaban, como el orden de evaluación de subexpresiones no especificado. Originalmente se creía que esto ayudaba a los escritores de compiladores a optimizar mejor. Hoy en día, los compiladores son lo suficientemente buenos como para descubrirlo de todos modos, pero el costo de encontrar todos los lugares en los compiladores existentes que aprovechan la libertad es demasiado alto.
fuente
Como un ejemplo, los accesos de puntero casi tienen que estar indefinidos y no necesariamente solo por razones de rendimiento. Por ejemplo, en algunos sistemas, cargar registros específicos con un puntero generará una excepción de hardware. En SPARC acceder a un objeto de memoria alineado incorrectamente causará un error de bus, pero en x86 sería "simplemente" lento. Es complicado especificar el comportamiento en esos casos, ya que el hardware subyacente dicta lo que sucederá, y C ++ es portátil para tantos tipos de hardware.
Por supuesto, también le da al compilador la libertad de usar conocimientos específicos de arquitectura. Para un ejemplo de comportamiento no especificado, el desplazamiento a la derecha de los valores con signo puede ser lógico o aritmético dependiendo del hardware subyacente, para permitir el uso de cualquier operación de desplazamiento disponible y no forzar la emulación del software.
Creo que también facilita el trabajo del compilador-escritor, pero no puedo recordar el ejemplo por ahora. Lo agregaré si recuerdo la situación.
fuente
Simple: velocidad y portabilidad. Si C ++ garantiza que tiene una excepción al desreferenciar un puntero no válido, entonces no sería portátil para el hardware incorporado. Si C ++ garantizara otras cosas, como primitivas siempre inicializadas, entonces sería más lento, y en el momento del origen de C ++, más lento era algo muy, muy malo.
fuente
C se inventó en una máquina con bytes de 9 bits y sin unidad de punto flotante. Supongamos que hubiera ordenado que los bytes sean de 9 bits, palabras de 18 bits y que los flotadores se implementen utilizando la aritmática pre IEEE754.
fuente
No creo que la primera razón para UB sea dejar espacio al compilador para optimizar, pero solo la posibilidad de usar la implementación obvia para los objetivos en un momento en que las arquitecturas tenían más variedad que ahora (recuerde si C fue diseñado en un PDP-11, que tiene una arquitectura algo familiar, el primer puerto fue Honeywell 635, que es mucho menos familiar: direccionable por palabras, usando palabras de 36 bits, bytes de 6 o 9 bits, direcciones de 18 bits ... bueno, al menos usó 2's complemento). Pero si la optimización no era un objetivo, la implementación obvia no incluye agregar comprobaciones de tiempo de ejecución para el desbordamiento, el recuento de turnos sobre el tamaño del registro, que alias en expresiones que modifican múltiples valores.
Otra cosa que se tuvo en cuenta fue la facilidad de implementación. El compilador de CA en ese momento tenía múltiples pases utilizando múltiples procesos porque tener un proceso que manejara todo no hubiera sido posible (el programa habría sido demasiado grande). Solicitar una gran verificación de coherencia estaba fuera del camino, especialmente cuando involucraba varias CU. (Para eso se utilizó otro programa que los compiladores de C, lint).
fuente
i
yn
, de tal manera quen < INT_BITS
yi*(1<<n)
no se desborde, consideraríai<<=n;
a ser más claro quei=(unsigned)i << n;
; en muchas plataformas sería más rápido y más pequeño quei*=(1<<N);
. ¿Qué se gana si los compiladores lo prohíben?Uno de los primeros casos clásicos fue la suma de enteros firmados. En algunos de los procesadores en uso, eso causaría una falla, y en otros simplemente continuaría con un valor (probablemente el valor modular apropiado). Especificar cualquier caso significaría que los programas para máquinas con el estilo aritmético desfavorable tendrían que tener un código adicional, incluida una rama condicional, para algo tan similar como la suma de enteros.
fuente
int
hay 16 bits y los turnos con signo extendido son caros podría calcular(uchar1*uchar2) >> 4
utilizando un turno sin signo extendido. Desafortunadamente, algunos compiladores extienden las inferencias no solo a los resultados, sino también a los operandos.Diría que se trató menos de filosofía que de realidad: C siempre ha sido un lenguaje multiplataforma, y el estándar debe reflejar eso y el hecho de que en el momento en que se publique cualquier estándar, habrá un gran cantidad de implementaciones en una gran cantidad de hardware diferente. Un estándar que prohíbe el comportamiento necesario sería ignorado o produciría un cuerpo de estándares competidores.
fuente
Algunos comportamientos no pueden definirse por ningún medio razonable. Me refiero a acceder a un puntero eliminado. La única forma de detectarlo sería prohibir el valor del puntero después de la eliminación (memorizar su valor en algún lugar y no permitir que ninguna función de asignación lo devuelva más). No solo una memorización de este tipo sería exagerada, sino que para un programa de ejecución prolongada se agotarían los valores de punteros permitidos.
fuente
weak_ptr
y anular todas las referencias a un puntero que sedelete
d ... oh, espera, nos acercamos a la recolección de basura: /boost::weak_ptr
La implementación es una plantilla bastante buena para comenzar con este patrón de uso. En lugar de rastrear y anularweak_ptrs
externamente, unweak_ptr
solo contribuye alshared_ptr
recuento débil del s, y el recuento débil es básicamente un recuento al puntero mismo. Por lo tanto, puede anular elshared_ptr
sin tener que eliminarlo de inmediato. No es perfecto (todavía puede tener muchos vencidosweak_ptr
manteniendo el subyacenteshared_count
sin una buena razón), pero al menos es rápido y eficiente.Le daré un ejemplo en el que prácticamente no hay otra opción sensata que no sea un comportamiento indefinido. En principio, cualquier puntero podría apuntar a la memoria que contiene cualquier variable, con la pequeña excepción de las variables locales que el compilador puede saber que nunca han tomado su dirección. Sin embargo, para obtener un rendimiento aceptable en una CPU moderna, un compilador debe copiar valores variables en registros. Operar completamente sin memoria no es un arranque.
Esto básicamente te da dos opciones:
1) Elimine todo de los registros antes de cualquier acceso a través de un puntero, en caso de que el puntero apunte a la memoria de esa variable en particular. Luego cargue todo lo necesario nuevamente en el registro, en caso de que los valores se hayan cambiado a través del puntero.
2) Tener un conjunto de reglas para cuando un puntero puede alias una variable y cuando el compilador puede asumir que un puntero no alias una variable.
C opta por la opción 2, porque 1 sería terrible para el rendimiento. Pero entonces, ¿qué sucede si un puntero alias una variable de una manera que las reglas C prohíben? Dado que el efecto depende de si el compilador de hecho almacenó la variable en un registro, no hay forma de que el estándar C garantice definitivamente resultados específicos.
fuente
foo
a 42, y luego llama a un método que utiliza un puntero modificado de forma ilegítima para establecerfoo
44, puedo ver el beneficio de decir que hasta la próxima escritura "legítima"foo
, los intentos de leerlo pueden legítimamente produce 42 o 44, y una expresión comofoo+foo
podría incluso producir 86, pero veo mucho menos beneficio al permitir que el compilador haga inferencias extendidas e incluso retroactivas, cambiando el Comportamiento indefinido cuyos comportamientos "naturales" plausibles habrían sido benignos, en una licencia para generar código sin sentido.Históricamente, el comportamiento indefinido tenía dos propósitos principales:
Para evitar exigir a los autores del compilador que generen código para manejar condiciones que nunca se supone que ocurran.
Para permitir la posibilidad de que en ausencia de código para manejar explícitamente tales condiciones, las implementaciones pueden tener varios tipos de comportamientos "naturales" que, en algunos casos, serían útiles.
Como un ejemplo simple, en algunas plataformas de hardware, intentar sumar dos enteros con signo positivo cuya suma es demasiado grande para caber en un entero con signo producirá un entero con signo negativo en particular. En otras implementaciones, activará una trampa de procesador. Para que el estándar C imponga cualquiera de los comportamientos, se requerirá que los compiladores para plataformas cuyo comportamiento natural difiera del estándar tengan que generar un código adicional para obtener el comportamiento correcto, código que puede ser más costoso que el código para realizar la adición real. Peor aún, significaría que los programadores que quisieran el comportamiento "natural" tendrían que agregar aún más código adicional para lograrlo (y ese código adicional sería nuevamente más costoso que la adición).
Desafortunadamente, algunos autores de compiladores han tomado la filosofía de que los compiladores deben hacer todo lo posible para encontrar condiciones que evoquen comportamientos indefinidos y, suponiendo que tales situaciones nunca ocurran, sacar inferencias extensas de eso. Por lo tanto, en un sistema con 32 bits
int
, un código dado como:el estándar C permitiría al compilador decir que si q es 46341 o mayor, la expresión q * q producirá un resultado demasiado grande para caber en un
int
, causando en consecuencia un comportamiento indefinido, y como resultado el compilador tendrá derecho a asumir que no puede suceder y, por lo tanto, no sería necesario que se incremente*p
si lo hace. Si el código de llamada se usa*p
como un indicador de que debe descartar los resultados del cálculo, el efecto de la optimización puede ser tomar un código que hubiera arrojado resultados sensibles en sistemas que funcionan de casi cualquier forma imaginable con desbordamiento de enteros (la captura puede ser feo, pero al menos sería sensato), y lo convirtió en un código que puede comportarse sin sentido.fuente
La eficiencia es la excusa habitual, pero sea cual sea la excusa, el comportamiento indefinido es una idea terrible para la portabilidad. En efecto, los comportamientos indefinidos se convierten en suposiciones no verificadas y no declaradas.
fuente