¿Por qué C ++ tiene 'comportamiento indefinido' (UB) y otros lenguajes como C # o Java no?

50

Esta publicación de Stack Overflow enumera una lista bastante completa de situaciones en las que la especificación del lenguaje C / C ++ declara que es un "comportamiento indefinido". Sin embargo, quiero entender por qué otros lenguajes modernos, como C # o Java, no tienen el concepto de "comportamiento indefinido". ¿Significa que el diseñador del compilador puede controlar todos los escenarios posibles (C # y Java) o no (C y C ++)?

Sisir
fuente
3
y, sin embargo, esta publicación SO se refiere a un comportamiento indefinido incluso en la especificación de Java.
gbjbaanb
"¿Por qué C ++ tiene 'Comportamiento indefinido'?" Desafortunadamente, esta parece ser una de esas preguntas que es difícil de responder objetivamente, más allá de la declaración "porque, por razones X, Y y / o Z (todo lo cual puede ser nullptr) no uno se molestó en definir el comportamiento escribiendo y / o adoptando una especificación propuesta ". : c
code_dredd
Desafiaría la premisa. Al menos C # tiene un código "inseguro". Microsoft escribe "En cierto sentido, escribir código inseguro es muy parecido a escribir código C dentro de un programa C #" y da ejemplos de razones por las que uno querría hacerlo: para acceder al hardware o al sistema operativo y para mayor velocidad. Para esto se inventó C (¡demonios, escribieron el sistema operativo en C!), Así que ahí lo tienen.
Peter - Restablece a Monica el

Respuestas:

72

El comportamiento indefinido es una de esas cosas que fueron reconocidas como una muy mala idea solo en retrospectiva.

Los primeros compiladores fueron grandes logros y celebraron con júbilo las mejoras sobre la alternativa: lenguaje de máquina o programación en lenguaje ensamblador. Los problemas con eso eran bien conocidos, y se inventaron lenguajes de alto nivel específicamente para resolver esos problemas conocidos. (El entusiasmo en ese momento era tan grande que las HLL fueron a veces aclamadas como "el final de la programación", como si de ahora en adelante solo tuviéramos que escribir trivialmente lo que queríamos y el compilador haría todo el trabajo real).

No fue hasta más tarde que nos dimos cuenta de los nuevos problemas que surgieron con el nuevo enfoque. Estar alejado de la máquina real en la que se ejecuta el código significa que hay más posibilidades de que las cosas silenciosamente no hagan lo que esperábamos que hicieran. Por ejemplo, la asignación de una variable normalmente dejaría el valor inicial sin definir; esto no se consideró un problema, porque no asignaría una variable si no quisiera mantener un valor en ella, ¿verdad? Seguramente no era demasiado esperar que los programadores profesionales no olvidaran asignar el valor inicial, ¿verdad?

Resultó que con las bases de código más grandes y las estructuras más complicadas que se hicieron posibles con sistemas de programación más potentes, sí, muchos programadores cometerían tales descuidos de vez en cuando, y el comportamiento indefinido resultante se convirtió en un problema importante. Incluso hoy, la mayoría de las fugas de seguridad de pequeñas a horribles son el resultado de un comportamiento indefinido de una forma u otra. (La razón es que, por lo general, el comportamiento indefinido está de hecho muy definido por las cosas en el siguiente nivel inferior en informática, y los atacantes que entienden ese nivel pueden usar ese margen de maniobra para hacer que un programa no solo haga cosas no intencionadas, sino exactamente las cosas que tienen la intención.)

Desde que reconocimos esto, ha habido un impulso general para desterrar el comportamiento indefinido de los lenguajes de alto nivel, y Java fue particularmente cuidadoso al respecto (lo cual fue relativamente fácil ya que de todos modos fue diseñado para ejecutarse en su propia máquina virtual específicamente diseñada). Los lenguajes más antiguos como C no se pueden adaptar fácilmente sin perder la compatibilidad con la gran cantidad de código existente.

Editar: Como se señaló, la eficiencia es otra razón. El comportamiento indefinido significa que los escritores de compiladores tienen mucho margen de maniobra para explotar la arquitectura de destino para que cada implementación se salga con la implementación más rápida posible de cada característica. Esto fue más importante en las máquinas con poca potencia de ayer que hoy, cuando el salario del programador es a menudo el cuello de botella para el desarrollo de software.

Kilian Foth
fuente
56
No creo que mucha gente de la comunidad C esté de acuerdo con esta declaración. Si modificara C y definiera un comportamiento indefinido (por ejemplo, inicializa todo por defecto, elige un orden de evaluación para el parámetro de función, etc.), la gran base de código con buen comportamiento continuaría funcionando perfectamente. Solo el código que no estaría bien definido hoy sería interrumpido. Por otro lado, si se deja sin definir como hoy, los compiladores seguirían siendo libres de explotar nuevos avances en arquitecturas de CPU y optimización de código.
Christophe
13
La parte principal de la respuesta no me parece realmente convincente. Quiero decir, es básicamente imposible escribir una función que agregue dos números de manera segura (como en int32_t add(int32_t x, int32_t y)) en C ++. Los argumentos habituales en torno a ese están relacionados con la eficiencia, pero a menudo se intercalan con algunos argumentos de portabilidad (como en "Escribir una vez, ejecutar ... en la plataforma donde lo escribió ... y en ningún otro lugar ;-)"). Aproximadamente, un argumento podría ser: Algunas cosas no están definidas porque no sabes si estás en un microcontoller de 16 bits o en un servidor de 64 bits (uno débil, pero sigue siendo un argumento)
Marco13
12
@ Marco13 estuvo de acuerdo, y deshacerse del problema del "comportamiento indefinido" haciendo algo "comportamiento definido, pero no necesariamente lo que el usuario quería y sin previo aviso cuando sucede" en lugar de "comportamiento indefinido" es solo jugar juegos de código-abogado IMO .
alephzero
99
"Incluso hoy, la mayoría de las filtraciones de seguridad de pequeñas a horribles son el resultado de un comportamiento indefinido de una forma u otra". Cita necesaria. Pensé que la mayoría de ellos eran inyección XYZ ahora.
Joshua
34
"El comportamiento indefinido es una de esas cosas que fueron reconocidas como una muy mala idea solo en retrospectiva". Esa es tu opinión. Muchos (incluido yo mismo) no lo comparten.
Lightness compite con Monica el
103

Básicamente porque los diseñadores de Java y lenguajes similares no querían un comportamiento indefinido en su lenguaje. Esto fue una compensación: permitir un comportamiento indefinido tiene el potencial de mejorar el rendimiento, pero los diseñadores de lenguaje priorizaron la seguridad y la previsibilidad más alto.

Por ejemplo, si asigna una matriz en C, los datos no están definidos. En Java, todos los bytes deben inicializarse a 0 (o algún otro valor especificado). Esto significa que el tiempo de ejecución debe pasar sobre la matriz (una operación O (n)), mientras que C puede realizar la asignación en un instante. Entonces C siempre será más rápido para tales operaciones.

Si el código que usa la matriz se va a llenar de todos modos antes de leer, esto es básicamente un esfuerzo perdido para Java. Pero en el caso en el que el código se lee primero, obtienes resultados predecibles en Java pero resultados impredecibles en C.

JacquesB
fuente
19
Excelente presentación del dilema HLL: seguridad y facilidad de uso vs. rendimiento. No hay bala de plata: hay casos de uso para cada lado.
Christophe
55
@Christophe Para ser justos, hay enfoques mucho mejores para un problema que dejar que UB vaya totalmente incontestado como C y C ++. Podría tener un lenguaje seguro y administrado, con escotillas de escape en territorio inseguro, para que lo aplique donde sea beneficioso. TBH, sería realmente bueno poder compilar mi programa C / C ++ con una bandera que dice "inserte cualquier maquinaria costosa en tiempo de ejecución que necesite, no me importa, pero cuénteme sobre TODA la UB que ocurre ".
Alexander
44
Un buen ejemplo de una estructura de datos que lee deliberadamente ubicaciones no inicializadas es la escasa representación de conjuntos de Briggs y Torczon (por ejemplo, ver codingplayground.blogspot.com/2009/03/… ) La inicialización de dicho conjunto es O (1) en C, pero O ( n) con la inicialización forzada de Java.
Arch D. Robison
99
Si bien es cierto que forzar la inicialización de datos hace que los programas rotos sean mucho más predecibles, no garantiza el comportamiento deseado: si el algoritmo espera leer datos significativos mientras lee erróneamente el cero inicializado implícitamente, eso es tanto un error como si hubiera tenido lee algo de basura. Con un programa C / C ++, dicho error sería visible ejecutando el proceso valgrind, que mostraría exactamente dónde se usó el valor no inicializado. No puede usar valgrindcódigo java porque el tiempo de ejecución realiza la inicialización, lo que hace que valgrindlas comprobaciones sean inútiles.
cmaster
55
@cmaster Es por eso que el compilador de C # no le permite leer de locales no inicializados. No es necesario realizar verificaciones en tiempo de ejecución, no es necesario inicializar, solo análisis en tiempo de compilación. Sin embargo, todavía es una compensación: hay algunos casos en los que no tiene una buena manera de manejar la ramificación alrededor de locales potencialmente no asignados. En la práctica, no he encontrado ningún caso en el que este no fuera un mal diseño en primer lugar y se resolviera mejor al repensar el código para evitar la ramificación complicada (que es difícil de analizar para los humanos), pero al menos es posible.
Luaan
42

El comportamiento indefinido permite una optimización significativa, al darle al compilador la libertad de hacer algo extraño o inesperado (o incluso normal) en ciertos límites u otras condiciones.

Ver http://blog.llvm.org/2011/05/what-every-c-programmer-should-know.html

Uso de una variable no inicializada: esto se conoce comúnmente como fuente de problemas en los programas en C y existen muchas herramientas para detectarlos: desde advertencias del compilador hasta analizadores estáticos y dinámicos. Esto mejora el rendimiento al no requerir que todas las variables se inicialicen en cero cuando entran en el alcance (como lo hace Java). Para la mayoría de las variables escalares, esto causaría poca sobrecarga, pero las matrices de pila y la memoria mal asignada incurrirían en un conjunto de memoria del almacenamiento, lo que podría ser bastante costoso, particularmente porque el almacenamiento generalmente se sobrescribe por completo.


Desbordamiento de entero firmado: si la aritmética en un tipo 'int' (por ejemplo) se desborda, el resultado es indefinido. Un ejemplo es que no se garantiza que "INT_MAX + 1" sea INT_MIN. Este comportamiento permite ciertas clases de optimizaciones que son importantes para algunos códigos. Por ejemplo, saber que INT_MAX + 1 no está definido permite optimizar "X + 1> X" a "verdadero". Saber que la multiplicación "no puede" desbordarse (porque hacerlo sería indefinido) permite optimizar "X * 2/2" a "X". Si bien esto puede parecer trivial, este tipo de cosas están comúnmente expuestas por la inserción y la expansión macro. Una optimización más importante que esto permite es para "<=" bucles como este:

for (i = 0; i <= N; ++i) { ... }

En este bucle, el compilador puede suponer que el bucle iterará exactamente N + 1 veces si "i" no está definida en el desbordamiento, lo que permite que se active una amplia gama de optimizaciones de bucle. Por otro lado, si la variable se define como En caso de desbordamiento, el compilador debe asumir que el bucle es posiblemente infinito (lo que sucede si N es INT_MAX), lo que deshabilita estas importantes optimizaciones del bucle. Esto afecta particularmente a las plataformas de 64 bits ya que tanto código usa "int" como variables de inducción.

Erik Eidt
fuente
27
Por supuesto, la verdadera razón por la cual el desbordamiento de enteros con signo no está definido es que cuando se desarrolló C, había al menos tres representaciones diferentes de enteros con signo en uso (complemento de uno, complemento de dos, magnitud de signo y tal vez binario compensado) , y cada uno da un resultado diferente para INT_MAX + 1. Hacer que el desbordamiento sea indefinido permite a + bque se compile a la add b ainstrucción nativa en cada situación, en lugar de requerir un compilador para simular alguna otra forma de aritmética de enteros con signo.
Mark
2
Permitir que los desbordamientos de enteros se comporten de manera poco definida permite optimizaciones significativas en los casos en que todos los comportamientos posibles cumplirían los requisitos de la aplicación . Sin embargo, la mayoría de esas optimizaciones se perderán si se requiere que los programadores eviten los desbordamientos de enteros a toda costa.
supercat
55
@supercat, que es otra razón por la cual evitar el comportamiento indefinido es más común en lenguajes más recientes: el tiempo del programador se valora mucho más que el tiempo de la CPU. El tipo de optimizaciones que C puede hacer gracias a UB son esencialmente inútiles en las computadoras de escritorio modernas y hacen que el razonamiento sobre el código sea mucho más difícil (sin mencionar las implicaciones de seguridad). Incluso en el código crítico de rendimiento, puede beneficiarse de optimizaciones de alto nivel que serían algo más difíciles (o incluso mucho más difíciles de hacer) en C.Tengo mi propio renderizador 3D de software en C #, y poder usar, por ejemplo, a HashSetes maravilloso.
Luaan
2
@supercat: Wrt_loosely defined_, la elección lógica para el desbordamiento de enteros sería requerir un comportamiento definido de implementación . Ese es un concepto existente, y no es una carga excesiva para las implementaciones. Sospecho que la mayoría se saldría con "es el complemento de 2 con envoltura". <<Podría ser el caso difícil.
MSalters el
@MSalters Hay una solución simple y bien estudiada que no es un comportamiento indefinido o un comportamiento definido por la implementación: comportamiento no determinista. Es decir, puede decir " x << yevalúa algún valor válido del tipo int32_tpero no diremos cuál". Esto permite a los implementadores usar la solución rápida, pero no actúa como una precondición falsa que permite optimizaciones de estilo de viaje en el tiempo porque el no determinismo está limitado a la salida de esta operación: la especificación garantiza que la memoria, las variables volátiles, etc. no se vean afectadas por la expresión evaluación. ...
Mario Carneiro
20

En los primeros días de C, había mucho caos. Diferentes compiladores trataron el lenguaje de manera diferente. Cuando había interés en escribir una especificación para el lenguaje, esa especificación tendría que ser bastante compatible con el C que los programadores confiaban con sus compiladores. Pero algunos de esos detalles no son portables y no tienen sentido en general, por ejemplo, suponiendo una resistencia particular o diseño de datos. Por lo tanto, el estándar C reserva muchos detalles como comportamiento indefinido o específico de implementación, lo que deja mucha flexibilidad a los escritores de compiladores. C ++ se basa en C y también presenta un comportamiento indefinido.

Java trató de ser un lenguaje mucho más seguro y más simple que C ++. Java define la semántica del lenguaje en términos de una máquina virtual completa. Esto deja poco espacio para el comportamiento indefinido, por otro lado, impone requisitos que pueden ser difíciles de hacer para una implementación de Java (por ejemplo, que las asignaciones de referencia deben ser atómicas o cómo funcionan los enteros). Cuando Java admite operaciones potencialmente inseguras, la máquina virtual generalmente las verifica en tiempo de ejecución (por ejemplo, algunos conversiones).

amon
fuente
Entonces, ¿está diciendo que la compatibilidad con versiones anteriores es la única razón por la cual C y C ++ no están saliendo de comportamientos indefinidos?
Sisir
3
Definitivamente es uno de los más grandes, @Sisir. Incluso entre los programadores con experiencia, que se sorprendería de la cantidad de cosas que no se deben romper hace descanso cuando un compilador cambia la forma en que maneja un comportamiento indefinido. (Caso en cuestión, hubo un poco de caos cuando GCC comenzó a optimizar "¿es thisnulo?" Hace un tiempo atrás, con el argumento de que thisser nullptrUB, y por lo tanto nunca puede suceder.)
Justin Time 2 Reinstate Monica el
99
@Sisir, otro gran es la velocidad. En los primeros días de C, el hardware era mucho más heterogéneo de lo que es hoy. Simplemente no especificando qué sucede cuando agrega 1 a INT_MAX, puede dejar que el compilador haga lo que sea más rápido para la arquitectura (por ejemplo, un sistema de complemento de uno producirá -INT_MAX, mientras que un sistema de complemento de dos producirá INT_MIN). Del mismo modo, al no especificar lo que sucede cuando se lee más allá del final de una matriz, puede hacer que un sistema con protección de memoria termine el programa, mientras que uno sin necesidad de implementar costosa verificación de límites de tiempo de ejecución.
Mark
14

Los lenguajes JVM y .NET lo tienen fácil:

  1. No tienen que poder trabajar directamente con hardware.
  2. Solo tienen que trabajar con sistemas de escritorio y servidor modernos o dispositivos razonablemente similares, o al menos dispositivos diseñados para ellos.
  3. Pueden imponer la recolección de basura para toda la memoria y la inicialización forzada, obteniendo así seguridad de puntero.
  4. Fueron especificados por un solo actor que también proporcionó la implementación definitiva única.
  5. Pueden elegir la seguridad sobre el rendimiento.

Sin embargo, hay buenos puntos para las opciones:

  1. La programación de sistemas es un juego de pelota completamente diferente, y la optimización sin concesiones para la programación de aplicaciones es razonable.
  2. Es cierto que hay hardware menos exótico todo el tiempo, pero los pequeños sistemas integrados están aquí para quedarse.
  3. GC no es adecuado para recursos no fungibles, e intercambia mucho más espacio para un buen rendimiento. Y la mayoría (pero no casi todas) de las inicializaciones forzadas se pueden optimizar.
  4. Hay ventajas para una mayor competencia, pero los comités significan compromiso.
  5. Todos esos límites controles no se suman, aunque la mayoría se pueden optimizar de distancia. Las comprobaciones de puntero nulo se pueden realizar principalmente atrapando el acceso a cero sobrecarga gracias al espacio de direcciones virtuales, aunque la optimización aún está inhibida.

Cuando se proporcionan escotillas de escape, los que invitan a un comportamiento indefinido en toda regla vuelven a entrar. Pero, al menos, generalmente solo se usan en pocos tramos muy cortos, por lo que son más fáciles de verificar manualmente.

Deduplicador
fuente
3
En efecto. Programa en C # para mi trabajo. De vez en cuando alcanzo uno de los martillos inseguros ( unsafepalabra clave o atributos en System.Runtime.InteropServices). Al mantener estas cosas a los pocos programadores que saben cómo depurar cosas no administradas y nuevamente tan poco como sea práctico, mantenemos los problemas bajos. Han pasado más de 10 años desde el último martillo inseguro relacionado con el rendimiento, pero a veces hay que hacerlo porque literalmente no hay otra solución.
Joshua
19
Frecuentemente trabajo en una plataforma desde dispositivos analógicos donde sizeof (char) == sizeof (short) == sizeof (int) == sizeof (float) == 1. También hace una adición de saturación (entonces INT_MAX + 1 == INT_MAX) , y lo bueno de C es que puedo tener un compilador conforme que genera un código razonable. Si el lenguaje ordenado dice que dos se complementan con una envoltura, entonces cada adición terminaría con una prueba y una rama, algo así como un no iniciador en una parte centrada en DSP. Esta es una parte de producción actual.
Dan Mills
55
@BenVoigt Algunos de nosotros vivimos en un mundo donde una computadora pequeña tiene quizás 4k de espacio de código, una pila fija de llamada / retorno de 8 niveles, 64 bytes de RAM, un reloj de 1MHz y cuesta <$ 0.20 en cantidad 1,000. Un teléfono móvil moderno es una PC pequeña con almacenamiento prácticamente ilimitado para todos los efectos, y puede tratarse prácticamente como una PC. No todo el mundo es multinúcleo y carece de restricciones difíciles en tiempo real.
Dan Mills
2
@DanMills: Aquí no hablamos de teléfonos móviles modernos con procesadores Arm Cortex A, hablamos de "teléfonos con funciones" alrededor de 2002. Sí 192kB de SRAM es mucho más de 64 bytes (que no es "pequeño" sino "pequeño"), pero 192kB tampoco se ha llamado con precisión escritorio o servidor "moderno" durante 30 años. Además, estos 20 centavos le darán un MSP430 con más de 64 bytes de SRAM.
Ben Voigt
2
@BenVoigt 192kB podría no ser una computadora de escritorio en los últimos 30 años, pero puedo asegurarle que es completamente suficiente para servir páginas web, lo que diría que hace que un servidor sea tal por la propia definición de la palabra. El hecho es que es una cantidad de RAM completamente razonable (generosa, incluso) para MUCHAS aplicaciones integradas que a menudo incluyen servidores web de configuración. Claro, probablemente no estoy ejecutando Amazon en él, pero podría estar ejecutando un refrigerador completo con crapware IOT en ese núcleo (con tiempo y espacio de sobra). ¡Nadie necesita idiomas interpretados o JIT para eso!
Dan Mills el
8

Java y C # se caracterizan por un proveedor dominante, al menos al principio de su desarrollo. (Sun y Microsoft respectivamente). C y C ++ son diferentes; Han tenido múltiples implementaciones competitivas desde el principio. C también funcionó especialmente en plataformas de hardware exóticas. Como resultado, hubo variación entre las implementaciones. Los comités ISO que estandarizaron C y C ++ podrían acordar un denominador común grande, pero en los bordes donde las implementaciones difieren, los estándares dejan espacio para la implementación.

Esto también se debe a que elegir un comportamiento puede ser costoso en arquitecturas de hardware que están sesgadas hacia otra opción: la endianidad es la opción obvia.

MSalters
fuente
¿Qué significa literalmente un "denominador común grande" ? ¿Estás hablando de subconjuntos o superconjuntos? ¿Realmente te refieres a suficientes factores en común? ¿Es esto como el mínimo común múltiplo o el mayor factor común? Esto es muy confuso para nosotros, los robots que no hablan jerga callejera, solo matemáticas. :)
tchrist
@tchrist: El comportamiento común es un subconjunto, pero este subconjunto es bastante abstracto. En muchas áreas no especificadas por el estándar común, las implementaciones reales deben tomar una decisión. Ahora, algunas de esas opciones son bastante claras y, por lo tanto, están definidas en la implementación, pero otras son más vagas. El diseño de la memoria en tiempo de ejecución es un ejemplo: debe haber una opción, pero no está claro cómo lo documentaría.
MSalters el
2
La C original fue hecha por un chico. Ya tenía un montón de UB, por diseño. Las cosas ciertamente empeoraron a medida que C se hizo popular, pero UB estuvo allí desde el principio. Pascal y Smalltalk tenían mucho menos UB y se desarrollaron casi al mismo tiempo. La principal ventaja de C era que era extremadamente fácil de portar: todos los problemas de portabilidad se delegaron al programador de la aplicación: P Incluso he portado un compilador de C simple a mi CPU (virtual); hacer algo como LISP o Smalltalk hubiera sido un esfuerzo mucho mayor (aunque tenía un prototipo limitado para un tiempo de ejecución .NET :).
Luaan
@Luaan: ¿Sería Kernighan o Ritchie? Y no, no tenía un comportamiento indefinido. Lo sé, he tenido la documentación original del compilador de AT&T en mi escritorio. La implementación hizo lo que hizo. No hubo distinción entre comportamiento no especificado e indefinido.
MSalters el
44
@MSalters Ritchie fue el primer chico. Kernighan solo se unió (no mucho) más tarde. Bueno, no tenía "Comportamiento indefinido", porque ese término aún no existía. Pero tenía el mismo comportamiento que hoy se llamaría indefinido. Como C no tenía una especificación, incluso "no especificado" es una exageración :) Era algo que al compilador no le importaba, y los detalles dependían de los programadores de aplicaciones. No fue diseñado para producir aplicaciones portátiles , solo el compilador estaba destinado a ser fácil de portar.
Luaan
6

La verdadera razón se reduce a una diferencia fundamental en la intención entre C y C ++ por un lado, y Java y C # (por solo un par de ejemplos) por el otro. Por razones históricas, gran parte de la discusión aquí habla sobre C en lugar de C ++, pero (como probablemente ya sepa) C ++ es un descendiente bastante directo de C, por lo que lo que dice sobre C se aplica igualmente a C ++.

Aunque en gran parte se olvidan (y su existencia a veces incluso se niega), las primeras versiones de UNIX se escribieron en lenguaje ensamblador. Gran parte (si no únicamente) del propósito original de C era el puerto UNIX del lenguaje ensamblador a un lenguaje de nivel superior. Parte de la intención era escribir la mayor cantidad posible del sistema operativo en un lenguaje de nivel superior, o mirarlo desde la otra dirección, para minimizar la cantidad que tenía que escribirse en lenguaje ensamblador.

Para lograr eso, C necesitaba proporcionar casi el mismo nivel de acceso al hardware que el lenguaje ensamblador. El PDP-11 (por ejemplo) asignó registros de E / S a direcciones específicas. Por ejemplo, leería una ubicación de memoria para verificar si se presionó una tecla en la consola del sistema. Se estableció un bit en esa ubicación cuando había datos esperando ser leídos. Luego leería un byte de otra ubicación especificada para recuperar el código ASCII de la tecla que se había presionado.

Del mismo modo, si quisiera imprimir algunos datos, verificaría otra ubicación especificada y, cuando el dispositivo de salida estuviera listo, escribiría sus datos en otra ubicación especificada.

Para admitir la escritura de controladores para dichos dispositivos, C le permitió especificar una ubicación arbitraria utilizando algún tipo de entero, convertirlo en un puntero y leer o escribir esa ubicación en la memoria.

Por supuesto, esto tiene un problema bastante serio: no todas las máquinas en la tierra tienen su memoria idéntica a una PDP-11 de principios de los años setenta. Entonces, cuando tomas ese número entero, lo conviertes en un puntero y luego lees o escribes a través de ese puntero, nadie puede proporcionar ninguna garantía razonable sobre lo que vas a obtener. Solo por un ejemplo obvio, la lectura y la escritura pueden correlacionarse con registros separados en el hardware, por lo que usted (al contrario de la memoria normal) si escribe algo, intente leerlo de nuevo, lo que lea puede no coincidir con lo que escribió.

Puedo ver algunas posibilidades que deja:

  1. Defina una interfaz para todo el hardware posible: especifique las direcciones absolutas de todas las ubicaciones que desee leer o escribir para interactuar con el hardware de cualquier manera.
  2. Prohibir ese nivel de acceso y decretar que cualquiera que quiera hacer tales cosas necesita usar lenguaje ensamblador.
  3. Permita que la gente haga eso, pero deje que lean (por ejemplo) los manuales del hardware al que apuntan y escriban el código para que se ajuste al hardware que están utilizando.

De estos, 1 parece lo suficientemente absurdo como para que no valga la pena seguir discutiéndolo. 2 es básicamente tirar la intención básica del lenguaje Eso deja a la tercera opción como esencialmente la única que podrían considerar razonablemente.

Otro punto que surge con bastante frecuencia es el tamaño de los tipos enteros. C toma la "posición" que intdebería ser el tamaño natural sugerido por la arquitectura. Entonces, si estoy programando un VAX de 32 bits, intprobablemente debería tener 32 bits, pero si estoy programando un Univac de 36 bits, intprobablemente debería tener 36 bits (y así sucesivamente). Probablemente no sea razonable (y puede que ni siquiera sea posible) escribir un sistema operativo para una computadora de 36 bits utilizando solo tipos que garanticen que sean múltiplos de 8 bits. Tal vez solo estoy siendo superficial, pero me parece que si estuviera escribiendo un sistema operativo para una máquina de 36 bits, probablemente querría usar un lenguaje que admitiera un tipo de 36 bits.

Desde el punto de vista del lenguaje, esto conduce a un comportamiento aún más indefinido. Si tomo el valor más grande que cabe en 32 bits, ¿qué sucederá cuando agregue 1? En el hardware típico de 32 bits, se va a dar la vuelta (o posiblemente arroje algún tipo de falla de hardware). Por otro lado, si se ejecuta en hardware de 36 bits, solo ... agregará uno. Si el lenguaje va a admitir la escritura de sistemas operativos, no puede garantizar ninguno de los dos comportamientos: solo tiene que permitir que tanto el tamaño de los tipos como el comportamiento del desbordamiento varíen de uno a otro.

Java y C # pueden ignorar todo eso. No están destinados a admitir la escritura de sistemas operativos. Con ellos, tienes un par de opciones. Una es hacer que el hardware admita lo que exigen, ya que exigen tipos de 8, 16, 32 y 64 bits, solo construya hardware que admita esos tamaños. La otra posibilidad obvia es que el lenguaje solo se ejecute sobre otro software que proporciona el entorno que desean, independientemente de lo que el hardware subyacente pueda desear.

En la mayoría de los casos, esto no es realmente una opción o una opción. Más bien, muchas implementaciones hacen un poco de ambas. Normalmente ejecuta Java en una JVM que se ejecuta en un sistema operativo. La mayoría de las veces, el sistema operativo se escribe en C y la JVM en C ++. Si la JVM se ejecuta en una CPU ARM, es muy probable que la CPU incluya las extensiones Jazelle de ARM, para adaptar el hardware más de cerca a las necesidades de Java, por lo que hay que hacer menos en el software y el código Java se ejecuta más rápido (o menos lentamente, de todos modos).

Resumen

C y C ++ tienen un comportamiento indefinido, porque nadie ha definido una alternativa aceptable que les permita hacer lo que deben hacer. C # y Java adoptan un enfoque diferente, pero ese enfoque se ajusta mal (si es que lo hace) con los objetivos de C y C ++. En particular, ninguno de los dos parece proporcionar una manera razonable de escribir software de sistema (como un sistema operativo) en la mayoría del hardware elegido arbitrariamente. Ambos suelen depender de las instalaciones proporcionadas por el software del sistema existente (generalmente escrito en C o C ++) para hacer su trabajo.

Jerry Coffin
fuente
4

Los autores de la Norma C esperaban que sus lectores reconocieran algo que pensaban que era obvio, y aludieron en su Justificación publicada, pero no dijeron directamente: el Comité no debería necesitar ordenar a los escritores de compiladores para satisfacer las necesidades de sus clientes, ya que los clientes deben saber mejor que el Comité cuáles son sus necesidades. Si es obvio que se espera que los compiladores para ciertos tipos de plataformas procesen una construcción de cierta manera, a nadie debería importarle si el Estándar dice que la construcción invoca Comportamiento indefinido. El hecho de que la Norma no exija que los compiladores conformes procesen una pieza de código de manera útil de ninguna manera implica que los programadores deberían estar dispuestos a comprar compiladores que no lo hagan.

Este enfoque del diseño del lenguaje funciona muy bien en un mundo donde los escritores de compiladores necesitan vender sus productos a clientes que pagan. Se desmorona por completo en un mundo donde los escritores de compiladores están aislados de los efectos del mercado. Es dudoso que existan las condiciones de mercado adecuadas para dirigir un idioma de la forma en que habían dirigido el que se hizo popular en la década de 1990, y aún más dudoso de que cualquier diseñador de idiomas sensato quisiera confiar en tales condiciones de mercado.

Super gato
fuente
Siento que has descrito algo importante aquí, pero se me escapa. ¿Podría aclarar su respuesta? Especialmente el segundo párrafo: dice que las condiciones ahora y las condiciones anteriores son diferentes, pero no lo entiendo; ¿Qué cambió exactamente? Además, el "camino" ahora es diferente al anterior; tal vez explicar esto también?
anatolyg
44
Parece que su campaña reemplaza todo comportamiento indefinido con un comportamiento no especificado o algo más restringido todavía se está fortaleciendo.
Deduplicador
1
@anatolyg: si aún no lo ha hecho, lea el documento publicado de C Rationale (escriba C99 Rationale en Google). Las líneas 11-29 de la página 11 hablan sobre el "mercado", y las líneas 5-8 de la página 13 hablan sobre lo que se pretende con respecto a la portabilidad. ¿Cómo cree que reaccionaría un jefe de una empresa compiladora comercial si un escritor del compilador le dijera a los programadores que se quejaron de que el optimizador rompió el código que cualquier otro compilador manejó de manera útil que su código estaba "roto" porque realiza acciones no definidas por el Estándar, y se negó a apoyarlo porque eso promovería la continuación ...
supercat
1
... uso de tales construcciones? Tal punto de vista es fácilmente evidente en los paneles de soporte de clang y gcc, y ha servido para impedir el desarrollo de intrínsecos que podrían facilitar la optimización de manera mucho más fácil y segura de lo que el lenguaje roto gcc y clang quieren soportar.
supercat
1
@supercat: Estás perdiendo el aliento quejándote con los vendedores del compilador. ¿Por qué no dirigir sus preocupaciones a los comités de idiomas? Si están de acuerdo con usted, se emitirá una errata que puede utilizar para vencer a los equipos compiladores en la cabeza. Y ese proceso es mucho más rápido que el desarrollo de una nueva versión del lenguaje. Pero si no están de acuerdo, al menos obtendrá razones reales, mientras que los escritores del compilador simplemente van a repetir (una y otra vez) "No designamos ese código como roto, esa decisión fue tomada por el comité de idiomas y nosotros seguir su decisión ".
Ben Voigt
3

C ++ y c tienen estándares descriptivos (las versiones ISO, de todos modos).

Que solo existen para explicar cómo funcionan los idiomas y para proporcionar una referencia única sobre el idioma. Por lo general, los vendedores de compiladores y los escritores de bibliotecas lideran el camino y algunas sugerencias se incluyen en el estándar ISO principal.

Java y C # (o Visual C #, que supongo que quiere decir) tienen estándares prescriptivos . Te dicen definitivamente qué hay en el idioma con anticipación, cómo funciona y qué se considera comportamiento permitido.

Más importante que eso, Java en realidad tiene una "implementación de referencia" en Open-JDK. (Creo que Roslyn cuenta como la implementación de referencia de Visual C #, pero no pude encontrar una fuente para eso).

En el caso de Java, si hay alguna ambigüedad en el estándar, y Open-JDK lo hace de cierta manera. La forma en que Open-JDK lo hace es el estándar.

quemador
fuente
La situación es peor que eso: no creo que el Comité haya logrado un consenso sobre si se supone que es descriptivo o prescriptivo.
supercat
1

El comportamiento indefinido permite al compilador generar código muy eficiente en una variedad de arquitectos. La respuesta de Erik menciona la optimización, pero va más allá de eso.

Por ejemplo, los desbordamientos firmados son comportamientos indefinidos en C. En la práctica, se esperaba que el compilador generara un código de operación de suma firmado simple para que la CPU se ejecute, y el comportamiento sería lo que hiciera esa CPU en particular.

Eso permitió a C funcionar muy bien y producir código muy compacto en la mayoría de las arquitecturas. Si el estándar hubiera especificado que los enteros con signo debían desbordarse de cierta manera, entonces las CPU que se comportaban de manera diferente habrían necesitado mucha más generación de código para una simple adición firmada.

Esa es la razón de gran parte del comportamiento indefinido en C, y por qué cosas como el tamaño de intvarían entre sistemas. Intdepende de la arquitectura y generalmente se selecciona para ser el tipo de datos más rápido y eficiente que es más grande que a char.

Cuando C era nuevo, estas consideraciones eran importantes. Las computadoras eran menos potentes, a menudo tenían una velocidad de procesamiento y memoria limitadas. C se usó donde el rendimiento realmente importaba, y se esperaba que los desarrolladores entendieran cómo funcionaban las computadoras lo suficientemente bien como para saber cuáles serían estos comportamientos indefinidos en sus sistemas particulares.

Los lenguajes posteriores como Java y C # prefirieron eliminar el comportamiento indefinido sobre el rendimiento sin procesar.

usuario
fuente
-5

En cierto sentido, Java también lo tiene. Supongamos que le dio un comparador incorrecto a Arrays.sort. Puede arrojar excepción de lo detecta. De lo contrario, ordenará una matriz de alguna manera que no se garantiza que sea particular.

Del mismo modo, si modifica la variable de varios hilos, los resultados también son impredecibles.

C ++ fue más allá para crear más situaciones indefinidas (o más bien Java decidió definir más operaciones) y tener un nombre para ello.

RiaD
fuente
44
Ese no es un comportamiento indefinido del tipo del que estamos hablando aquí. Los "comparadores incorrectos" vienen en dos tipos: los que definen un orden total y los que no. Si proporciona un comparador que define consistentemente el orden relativo de los elementos, el comportamiento está bien definido, simplemente no es el comportamiento que el programador quería. Si proporciona un comparador que no es consistente con el orden relativo, el comportamiento aún está bien definido: la función de clasificación generará una excepción (que probablemente tampoco sea el comportamiento que el programador quería).
Mark
2
En cuanto a la modificación de variables, las condiciones de carrera generalmente no se consideran comportamientos indefinidos. No conozco los detalles de cómo Java maneja las asignaciones a datos compartidos, pero conociendo la filosofía general del lenguaje, estoy bastante seguro de que es necesario que sea atómico. Asignar simultáneamente 53 y 71 asería un comportamiento indefinido si pudieras obtener 51 o 73, pero si solo puedes obtener 53 o 71, está bien definido.
Mark
@Mark Con fragmentos de datos mayores que el tamaño de palabra nativo del sistema (por ejemplo, una variable de 32 bits en un sistema de tamaño de palabra de 16 bits), es posible tener una arquitectura que requiera almacenar cada porción de 16 bits por separado. (SIMD es otra posible situación de este tipo). En ese caso, incluso una simple asignación de nivel de código fuente no es necesariamente atómica a menos que el compilador tenga especial cuidado para garantizar que se ejecute atómicamente.
un CVn el