¿Por qué x = x ++ no está definido?

19

No está definido porque se modifica xdos veces entre puntos de secuencia. El estándar dice que no está definido, por lo tanto, no está definido.
Eso lo sé.

¿Pero por qué?

Tengo entendido que prohibir esto permite a los compiladores optimizar mejor. Esto podría haber tenido sentido cuando se inventó C, pero ahora parece un argumento débil.
Si reinventamos C hoy, ¿lo haríamos de esta manera, o podríamos hacerlo mejor?
¿O tal vez hay un problema más profundo que hace que sea difícil definir reglas consistentes para tales expresiones, por lo que es mejor prohibirlas?

Supongamos que reinventamos C hoy. Me gustaría sugerir reglas simples para expresiones como x=x++, que me parecen funcionar mejor que las reglas existentes.
Me gustaría conocer su opinión sobre las reglas sugeridas en comparación con las existentes u otras sugerencias.

Reglas sugeridas:

  1. Entre los puntos de secuencia, el orden de evaluación no está especificado.
  2. Los efectos secundarios tienen lugar de inmediato.

No hay un comportamiento indefinido involucrado. Las expresiones evalúan este valor o ese, pero seguramente no formatearán su disco duro (curiosamente, nunca he visto una implementación donde x=x++formatee el disco duro).

Expresiones de ejemplo

  1. x=x++- Bien definido, no cambia x.
    Primero, xse incrementa (inmediatamente cuando x++se evalúa), luego se almacena su valor anterior x.

  2. x++ + ++x- Incrementa xdos veces, evalúa a 2*x+2.
    Aunque cualquier lado puede evaluarse primero, el resultado es x + (x+2)(lado izquierdo primero) o (x+1) + (x+1)(lado derecho primero).

  3. x = x + (x=3)- Sin especificar, xestablecido en x+3o 6.
    Si el lado derecho se evalúa primero, es x+3. También es posible que x=3se evalúe primero, así es 3+3. En cualquier caso, la x=3asignación ocurre inmediatamente cuando x=3se evalúa, por lo que el valor almacenado se sobrescribe con la otra asignación.

  4. x+=(x=3)- Bien definido, se establece xen 6.
    Se podría argumentar que esto es solo una forma abreviada de la expresión anterior.
    Pero diría que +=debe ejecutarse después x=3, y no en dos partes (leer x, evaluar x=3, agregar y almacenar nuevo valor).

¿Cuál es la ventaja?

Algunos comentarios plantearon este buen punto.
Ciertamente no creo que expresiones como x=x++deberían usarse en ningún código normal.
En realidad, soy mucho más estricto que eso: creo que el único buen uso x++es x++;solo.

Sin embargo, creo que las reglas del lenguaje deben ser lo más simples posible. De lo contrario, los programadores simplemente no los entienden. La regla que prohíbe cambiar una variable dos veces entre puntos de secuencia es ciertamente una regla que la mayoría de los programadores no entienden.

Una regla muy básica es esta:
si A es válido y B es válido, y se combinan de manera válida, el resultado es válido.
xes un valor L válido, x++es una expresión válida y =es una forma válida de combinar un valor L y una expresión, entonces, ¿por qué x=x++no es legal?
El estándar C hace una excepción aquí, y esta excepción complica las reglas. Puede buscar en stackoverflow.com y ver cuánto confunde esta excepción con las personas.
Así que digo: deshazte de esta confusión.

=== Resumen de respuestas ===

  1. ¿Por qué hacer eso?
    Traté de explicarlo en la sección anterior: quiero que las reglas de C sean simples.

  2. Potencial de optimización:
    esto toma algo de libertad del compilador, pero no vi nada que me convenciera de que podría ser significativo.
    La mayoría de las optimizaciones aún se pueden hacer. Por ejemplo, a=3;b=5;puede reordenarse, aunque el estándar especifique el orden. Expresiones como a=b[i++]todavía se pueden optimizar de manera similar.

  3. No puede cambiar el estándar existente.
    Lo admito, no puedo. Nunca pensé que podría seguir adelante y cambiar los estándares y los compiladores. Solo quería pensar si las cosas podrían haberse hecho de manera diferente.

Ugoren
fuente
10
Por qué esto es importante para ti? ¿Debería definirse, y si es así, por qué? No tiene mucho sentido asignarse xa sí mismo, y si desea aumentar x, puede decir x++;: no es necesario realizar la tarea. Yo diría que no debería definirse solo porque sería difícil recordar lo que se supone que sucederá.
Caleb
44
En mi opinión, esta es una buena pregunta ("Algunos hombres ven las cosas como son y preguntan por qué, sueño cosas que nunca fueron y preguntan por qué no"). Es (en mi opinión) una pregunta puramente sobre diseño de lenguaje, usando la sintaxis C como ejemplo, no una pregunta sobre la sintaxis C. Personalmente, creo que la razón por la que no tenemos un comportamiento definido para expresiones como x ++ + ++ x o x = x ++ es simplemente porque existe la posibilidad de que se lean mal.
Jamie Taylor
55
@ugoren: ¿Por qué necesitas predecir el resultado? Nadie en su sano juicio escribiría un código como ese (como se ha mencionado varias veces), incluso si escribiera un código como este, sería rechazado en la primera revisión del código. Por lo tanto, no es necesario definir el comportamiento y darle al optimizador la mejor oportunidad de optimizarlo. En cada ejemplo que propongas, dispararía a alguien si agregaran eso a la base del código.
Martin York
3
Una pregunta más interesante sería por qué no es un error escribir esto. Seguramente un compilador podría detectar su comportamiento indefinido y, por lo tanto, no puede ser lo que el usuario realmente quería, entonces, ¿por qué no hay un error? Entiendo que algunos casos de comportamiento indefinido son difíciles de detectar, pero esto no es; t
JohnB
3
" La regla que prohíbe cambiar una variable dos veces entre puntos de secuencia es ciertamente una regla que la mayoría de los programadores no entienden ". ¿Tiene alguna evidencia de esto? La mayoría de las preguntas que he visto se hicieron porque los programadores no sabían sobre la regla. ¿Hay alguna evidencia de que la mayoría de ellos todavía no lo entendieron después de que se explicó?
Seguro

Respuestas:

24

¿Quizás debería primero responder la pregunta por qué debería definirse? ¿Hay alguna ventaja en el estilo de programación, legibilidad, mantenibilidad o rendimiento al permitir tales expresiones con efectos secundarios adicionales? Es

y = x++ + ++x;

más legible que

y = 2*x + 2;
x += 2;

Dado que dicho cambio es extremadamente fundamental y rompe con la base de código existente.

Seguro
fuente
1
Agregué una sección de "por qué" a mi pregunta. Seguramente no sugiero usar estas expresiones, pero estoy interesado en tener reglas simples para decir el significado de una expresión.
ugoren
Además, este cambio no rompe el código existente, a menos que invoque un comportamiento indefinido. Corrígeme si me equivoco.
ugoren
3
Bueno, una respuesta más filosófica: actualmente no está definida. Si ningún programador lo usa, entonces no es necesario que comprenda tales expresiones, porque no debería haber ningún código. Si es necesario que usted los entienda, entonces obviamente debe haber mucho código que se base en un comportamiento indefinido. ;)
Seguro el
1
Es, por definición, no romper ninguna base de código existente para definir los comportamientos. Si contenían UB, por definición, ya estaban rotos.
DeadMG
1
@ugoren: Su sección "por qué" todavía no responde la pregunta práctica: ¿por qué querría esta expresión extraña en su código? Si no puede encontrar una respuesta convincente a eso, entonces toda la discusión es discutible.
Mike Baranczak
20

El argumento de que hacer que este comportamiento indefinido permita una mejor optimización no es débil hoy en día. De hecho, es mucho más fuerte hoy que cuando C era nuevo.

Cuando C era nuevo, las máquinas que podían aprovechar esto para una mejor optimización eran en su mayoría modelos teóricos. La gente había hablado sobre la posibilidad de construir CPUs donde el compilador instruiría a la CPU sobre qué instrucciones podrían / ​​deberían ejecutarse en paralelo con otras instrucciones. Señalaron el hecho de que permitir que esto tuviera un comportamiento indefinido significaba que en una CPU de este tipo, si alguna vez existió realmente, podría programar la parte de "incremento" de la instrucción para que se ejecute en paralelo con el resto del flujo de instrucciones. Si bien tenían razón sobre la teoría, en ese momento había poco en el camino del hardware que realmente pudiera aprovechar esta posibilidad.

Eso ya no es solo teórico. Ahora hay hardware en producción y en amplio uso (por ejemplo, Itanium, VLIW DSP) que realmente puede aprovechar esto. Realmente hacen permita que el compilador para generar un flujo de instrucciones que especifica que las instrucciones X, Y y Z pueden todos ser ejecutadas en paralelo. Esto ya no es un modelo teórico: es hardware real en uso real haciendo trabajo real.

En mi opinión, hacer que este comportamiento definido esté cerca de la peor "solución" posible al problema. Claramente no deberías usar expresiones como esta. Para la gran mayoría del código, el comportamiento ideal sería que el compilador simplemente rechazara tales expresiones por completo. En ese momento, los compiladores de C no hicieron el análisis de flujo necesario para detectar eso de manera confiable. Incluso en el momento del estándar C original, todavía no era del todo común.

Tampoco estoy seguro de que sea aceptable para la comunidad hoy en día, aunque muchos compiladores pueden hacer ese tipo de análisis de flujo, generalmente solo lo hacen cuando solicitas la optimización. Dudo que a la mayoría de los programadores les guste la idea de ralentizar las compilaciones de "depuración" solo por el hecho de poder rechazar el código que ellos (estar cuerdos) nunca escribirían en primer lugar.

Lo que C ha hecho es una segunda opción semi-razonable: decirle a la gente que no haga eso, permitiendo (pero no exigiendo) que el compilador rechace el código. Esto evita (aún más) ralentizar la compilación para las personas que nunca lo usarían, pero aún así permite que alguien escriba un compilador que rechace dicho código si quiere (y / o tiene indicadores que lo rechazarán que las personas pueden elegir usar) o no como mejor les parezca).

Al menos IMO, hacer este comportamiento definido sería (al menos cercano a) la peor decisión posible. En el hardware de estilo VLIW, sus opciones serían generar un código más lento para los usos razonables de los operadores de incremento, solo por el mal código que los abusa, o de lo contrario siempre requerirá un análisis de flujo extenso para demostrar que no está tratando con código defectuoso, para que pueda producir el código lento (serializado) solo cuando sea realmente necesario.

En pocas palabras: si desea curar este problema, debe pensar en la dirección opuesta. En lugar de definir qué hace ese código, debe definir el lenguaje para que tales expresiones simplemente no estén permitidas en absoluto (y vivir con el hecho de que la mayoría de los programadores probablemente optarán por una compilación más rápida que hacer cumplir ese requisito).

Jerry Coffin
fuente
En mi opinión, hay pocas razones para creer que en la mayoría de los casos, las instrucciones más lentas son realmente mucho más lentas que las instrucciones rápidas y que siempre tendrán un impacto en el rendimiento del programa. Clasificaría este bajo optimización prematura.
DeadMG
Tal vez me estoy perdiendo algo: si nadie se supone que escriba ese código, ¿por qué preocuparse por optimizarlo?
ugoren
1
@ugoren: escribir código como a=b[i++];(por ejemplo) está bien, y optimizarlo es algo bueno. Sin embargo, no veo el punto de dañar un código razonable como ese solo para que algo así ++i++tenga un significado definido.
Jerry Coffin
2
@ugoren El problema es de diagnóstico. El único propósito de no rechazar por completo expresiones como ++i++es precisamente que, en general, es difícil distinguirlas de expresiones válidas con efectos secundarios (como a=b[i++]). Puede parecer bastante simple para nosotros, pero si recuerdo el Libro del Dragón correctamente, en realidad es un problema NP-difícil. Es por eso que este comportamiento es UB, en lugar de prohibido.
Konrad Rudolph
1
No creo que el rendimiento sea un argumento válido. Me cuesta creer que el caso sea lo suficientemente común, teniendo en cuenta la muy pequeña diferencia y la ejecución muy rápida en ambos casos, para que se pueda notar una pequeña caída en el rendimiento, sin mencionar que en muchos procesadores y arquitecturas, definirlo es efectivamente gratuito.
DeadMG
9

Eric Lippert, diseñador principal del equipo del compilador de C #, publicó en su blog un artículo sobre una serie de consideraciones que se deben tomar para elegir hacer que una característica no esté definida en el nivel de especificación del lenguaje. Obviamente, C # es un lenguaje diferente, con diferentes factores que intervienen en su diseño del lenguaje, pero los puntos que señala son relevantes.

En particular, señala la cuestión de tener compiladores existentes para un lenguaje que tiene implementaciones existentes y también tiene representantes en un comité. No estoy seguro de si ese es el caso aquí, pero tiende a ser relevante para la mayoría de las discusiones de especificaciones relacionadas con C y C ++.

También es de destacar, como usted dijo, el potencial de rendimiento para la optimización del compilador. Si bien es cierto que el rendimiento de las CPU en estos días es mucho mayor de lo que eran cuando C era joven, una gran cantidad de programación C realizada en estos días se realiza específicamente debido a la ganancia potencial de rendimiento y el potencial para (futuro hipotético ) Las optimizaciones de instrucciones de CPU y las optimizaciones de procesamiento multinúcleo serían absurdas debido a un conjunto de reglas demasiado restrictivas para manejar los efectos secundarios y los puntos de secuencia.

Tanzelax
fuente
Desde el artículo al que se vincula, parece que C # no está lejos de lo que sugiero. El orden de los efectos secundarios se define "cuando se observa desde el hilo que causa los efectos secundarios". No mencioné los subprocesos múltiples, pero en general C no garantiza mucho para un observador en otro hilo.
ugoren
5

Primero, echemos un vistazo a la definición de comportamiento indefinido:

3.4.3

1 comportamiento de
comportamiento indefinido, al usar una construcción de programa no portable o errónea o de datos erróneos, para lo cual esta Norma Internacional no impone requisitos

2 NOTA El posible comportamiento indefinido varía desde ignorar la situación por completo con resultados impredecibles, hasta comportarse durante la traducción o la ejecución del programa en una manera documentada característica del entorno (con o sin la emisión de un mensaje de diagnóstico), para finalizar una traducción o ejecución (con la emisión de un mensaje de diagnóstico).

3 EJEMPLO Un ejemplo de comportamiento indefinido es el comportamiento en el desbordamiento de enteros

En otras palabras, "comportamiento indefinido" simplemente significa que el compilador es libre de manejar la situación de la forma que quiera, y cualquier acción de este tipo se considera "correcta".

La raíz del problema en discusión es la siguiente cláusula:

6.5 Expresiones

...
3 La agrupación de operadores y operandos se indica mediante la sintaxis. 74) , excepto tal como se especifica más adelante (para la función de guardia (), &&, ||, ?:, y operadores por comas), el orden de evaluación de subexpresiones y el orden en el que los efectos secundarios tienen lugar son ambos no especi fi ed .

Énfasis añadido.

Dada una expresión como

x = a++ * --b / (c + ++d);

las subexpresiones a++, --b, c, y ++dse pueden evaluar en cualquier orden . Además, los efectos secundarios de a++, --by ++dpueden aplicarse en cualquier punto antes del siguiente punto de secuencia (IOW, incluso si a++se evalúa antes --b, no se garantiza que ase actualizará antes de que --bse evalúe). Como otros han dicho, la razón de este comportamiento es dar a la implementación la libertad de reordenar las operaciones de manera óptima.

Debido a esto, sin embargo, expresiones como

x = x++
y = i++ * i++
a[i] = i++
*p++ = -*p    // this one bit me just yesterday

etc., arrojará resultados diferentes para diferentes implementaciones (o para la misma implementación con diferentes configuraciones de optimización, o basadas en el código circundante, etc.).

El comportamiento se deja sin definir para que el compilador no tenga la obligación de "hacer lo correcto", sea lo que sea. Los casos anteriores son bastante fáciles de detectar, pero hay un número no trivial de casos que sería difícil o imposible detectar en tiempo de compilación.

Obviamente, puede diseñar un lenguaje de manera que el orden de evaluación y el orden en el que se aplican los efectos secundarios estén estrictamente definidos, y tanto Java como C # lo hacen, en gran medida para evitar los problemas que provocan las definiciones de C y C ++.

Entonces, ¿por qué no se ha realizado este cambio en C después de 3 revisiones estándar? En primer lugar, hay 40 años de código C heredado, y no se garantiza que tal cambio no rompa ese código. Impone un poco de carga a los escritores de compiladores, ya que tal cambio inmediatamente haría que todos los compiladores existentes no sean conformes; todos tendrían que hacer reescrituras significativas. E incluso en CPU rápidas y modernas, aún es posible obtener ganancias de rendimiento reales ajustando el orden de evaluación.

John Bode
fuente
1
Muy buena explicación del problema. No estoy de acuerdo con romper las aplicaciones heredadas: la forma en que se implementa el comportamiento indefinido / no especificado a veces cambia entre la versión del compilador, sin ningún cambio en el estándar. No sugiero cambiar ningún comportamiento definido.
ugoren
4

Primero debe comprender que no solo x = x ++ es indefinido. A nadie le importa x = x ++, ya que no importa cómo lo defina, no tiene sentido. Lo que no está definido es más como "a = b ++ donde a y b son iguales", es decir

void f(int *a, int *b) {
    *a = (*b)++;
}
int i;
f(&i, &i);

Existen varias formas diferentes de implementar la función, dependiendo de lo que sea más eficiente para la arquitectura del procesador (y para las declaraciones que la rodean, en caso de que sea una función más compleja que el ejemplo). Por ejemplo, dos obvios:

load r1 = *b
copy r2 = r1
increment r1
store *b = r1
store *a = r2

o

load r1 = *b
store *a = r1
increment r1
store *b = r1

Tenga en cuenta que el primero que se menciona arriba, el que usa más instrucciones y más registros, es el que necesitaría usar en todos los casos en que ayb no puedan probarse que son diferentes.

Aleatorio832
fuente
De hecho, muestra un caso en el que mi sugerencia da como resultado más operaciones de la máquina, pero me parece insignificante. Y el compilador todavía tiene algo de libertad: el único requisito real que agrego es almacenar bantes a.
ugoren
3

Legado

La suposición de que C podría reinventarse hoy no puede sostenerse. Hay tantas líneas de códigos C que se han producido y se usan a diario, que cambiar las reglas del juego en el medio del juego es simplemente incorrecto.

Por supuesto, puede inventar un nuevo lenguaje, digamos C + = , con sus reglas. Pero eso no será C.

Mouviciel
fuente
2
Realmente no creo que podamos reinventar la C hoy. Esto no significa que no podamos discutir estos temas. Sin embargo, lo que sugiero no es realmente reinventar. La conversión de un comportamiento indefinido a definido o no especificado se puede hacer cuando se actualiza una norma, y el idioma seguiría siendo C
ugoren
2

Declarar que algo está definido no cambiará los compiladores existentes para respetar su definición. Esto es especialmente cierto en el caso de una suposición en la que se haya podido basar explícita o implícitamente en muchos lugares.

El problema principal para el supuesto no es con x = x++;(los compiladores pueden verificarlo fácilmente y deberían advertirlo), es con *p1 = (*p2)++y equivalente ( p1[i] = p2[j]++;cuando p1 y p2 son parámetros para una función) donde el compilador no puede saber fácilmente si p1 == p2(en C99 restrictse ha agregado para extender la posibilidad de asumir p1! = p2 entre puntos de secuencia, por lo que se consideró que las posibilidades de optimización eran importantes).

Un programador
fuente
No veo cómo mi sugerencia cambia algo con respecto a p1[i]=p2[j]++. Si el compilador puede asumir que no hay alias, no hay problema. Si no puede, debe pasar por el libro - Valor mínimo de p2[j]primera, almacenar p1[i]más tarde. Excepto por las oportunidades de optimización perdidas, que no parecen significativas, no veo ningún problema.
ugoren
El segundo párrafo no era independiente del primero, pero es un ejemplo del tipo de lugares donde la suposición puede colarse y será difícil de rastrear.
Programador
El primer párrafo establece algo bastante obvio: los compiladores deberán cambiarse para cumplir con un nuevo estándar. Realmente no creo que tenga la oportunidad de estandarizar esto y hacer que los escritores del compilador lo sigan. Solo creo que vale la pena discutirlo.
ugoren
El problema no es que uno necesite cambiar los compiladores sobre cualquier cambio en el idioma que lo necesite, es que los cambios son generalizados y difíciles de encontrar. El enfoque más práctico sería probablemente cambiará el formato intermedio en el que las obras del optimizador, es decir, pretendiendo que x = x++;no ha sido escrito, pero t = x; x++; x = t;ni x=x; x++;o como se quiera como semántica (pero ¿qué pasa con el diagnóstico?). Para un nuevo idioma, simplemente abandone los efectos secundarios.
Programador
No sé demasiado sobre la estructura del compilador. Si realmente quisiera cambiar todos los compiladores, me importaría más. Pero tal vez tratarlo x++como un punto de secuencia, como si fuera una llamada de función, inc_and_return_old(&x)sería el truco.
ugoren
-1

En algunos casos, este tipo de código se definió en el nuevo estándar C ++ 11.

DeadMG
fuente
55
¿Cuidado para elaborar?
ugoren
Creo que x = ++xahora está bien definido (pero no x = x++)
MM