c++ language-lawyer undefined-behavior

c++ - ¿Por qué f(i=-1, i=-1) comportamiento indefinido?



language-lawyer undefined-behavior (11)

Estaba leyendo sobre el orden de las violaciones de evaluación , y dan un ejemplo que me desconcierta.

1) Si un efecto secundario en un objeto escalar no se secuencia en relación con otro efecto secundario en el mismo objeto escalar, el comportamiento no está definido

// snip f(i = -1, i = -1); // undefined behavior

En este contexto, i es un objeto escalar , que aparentemente significa

Los tipos aritméticos (3.9.1), los tipos de enumeración, los tipos de puntero, los tipos de puntero a los miembros (3.9.2), std :: nullptr_t y las versiones calificadas con CV de estos tipos (3.9.3) se denominan colectivamente tipos escalares.

No veo cómo la declaración es ambigua en ese caso. Me parece que, independientemente de si el primer o el segundo argumento se evalúa primero, i termina como -1 , y ambos argumentos también son -1 .

¿Alguien puede aclarar?

ACTUALIZAR

Realmente aprecio toda la discusión. Hasta ahora, me gusta mucho la respuesta de @ harmic, ya que expone los inconvenientes y las complejidades de definir esta afirmación a pesar de lo sencillo que parece a primera vista. @acheong87 señala algunos problemas que surgen cuando se usan referencias, pero creo que eso es ortogonal al aspecto de los efectos secundarios no secuenciales de esta pregunta.

RESUMEN

Como esta pregunta atrajo mucha atención, resumiré los puntos / respuestas principales. Primero, permítanme una pequeña digresión para señalar que "por qué" puede tener significados estrechamente relacionados pero sutilmente diferentes, a saber, "por qué causa ", "por qué razón " y "con qué propósito ". Agruparé las respuestas según cuál de esos significados de "por qué" abordaron.

por que causa

La respuesta principal aquí proviene de Paul Draper , con Martin J contribuyendo con una respuesta similar pero no tan extensa. La respuesta de Paul Draper se reduce a

Es un comportamiento indefinido porque no está definido cuál es el comportamiento.

La respuesta es, en general, muy buena en términos de explicar lo que dice el estándar de C ++. También aborda algunos casos relacionados de UB como f(++i, ++i); y f(i=1, i=-1); . En el primero de los casos relacionados, no está claro si el primer argumento debería ser i+1 y el segundo i+2 o viceversa; en el segundo, no está claro si debería ser 1 o -1 después de la llamada a la función. Ambos casos son UB porque están comprendidos en la siguiente regla:

Si un efecto secundario en un objeto escalar no tiene secuencia en relación con otro efecto secundario en el mismo objeto escalar, el comportamiento no está definido.

Por lo tanto, f(i=-1, i=-1) también es UB, ya que cae bajo la misma regla, a pesar de que la intención del programador es (IMHO) obvia e inequívoca.

Paul Draper también hace explícito en su conclusión que

¿Podría haberse definido el comportamiento? Sí. ¿Fue definido? No.

lo que nos lleva a la pregunta de "¿por qué motivo / propósito fue f(i=-1, i=-1) como comportamiento indefinido?"

por que razon / proposito

Aunque hay algunos descuidos (quizás descuidados) en el estándar de C ++, muchas omisiones están bien razonadas y tienen un propósito específico. Aunque soy consciente de que el propósito es a menudo "facilitar el trabajo del compilador-escritor" o "código más rápido", me interesó principalmente saber si existe una buena razón para dejar f(i=-1, i=-1) como UB.

harmic y harmic proporcionan las principales respuestas que proporcionan una razón para la UB. Harmic señala que un compilador de optimización que podría dividir las operaciones de asignación aparentemente atómicas en múltiples instrucciones de la máquina, y que podría intercalar aún más esas instrucciones para una velocidad óptima. Esto podría llevar a algunos resultados muy sorprendentes: ¡termino con -2 en su escenario! Por lo tanto, harmic demuestra cómo asignar el mismo valor a una variable más de una vez puede tener efectos negativos si las operaciones no se realizan.

Supercat proporciona una exposición relacionada de los escollos de tratar de obtener f(i=-1, i=-1) para hacer lo que parece que debería hacer. Señala que en algunas arquitecturas, existen fuertes restricciones contra múltiples escrituras simultáneas en la misma dirección de memoria. Un compilador podría tener dificultades para captar esto si tratamos con algo menos trivial que f(i=-1, i=-1) .

davidf también proporciona un ejemplo de instrucciones de intercalación muy similares a las de Harmic.

Aunque cada uno de los ejemplos de harmic, supercat y davidf ''son un tanto idóneos, en conjunto, todavía sirven para proporcionar una razón tangible por la que f(i=-1, i=-1) debe ser un comportamiento indefinido.

Acepté la respuesta de Harmic porque hizo el mejor trabajo al abordar todos los significados de por qué, a pesar de que la respuesta de Paul Draper abordó la parte de "por qué causa" mejor.

Otras respuestas

JohnB señala que si consideramos operadores de asignación sobrecargados (en lugar de simples escalares), también podemos tener problemas.


A mí me parece que la única regla relacionada con la secuenciación de la expresión de argumento de función está aquí:

3) Cuando se llama a una función (ya sea que la función esté o no en línea, y si se usa o no la sintaxis de llamada de función explícita), cada cálculo de valor y efecto secundario asociado con cualquier expresión de argumento, o con la expresión de posfijo que designa la función llamada, es secuenciada antes de la ejecución de cada expresión o declaración en el cuerpo de la función llamada.

Esto no define la secuencia entre expresiones de argumento, por lo que terminamos en este caso:

1) Si un efecto secundario en un objeto escalar no tiene secuencia en relación con otro efecto secundario en el mismo objeto escalar, el comportamiento no está definido.

En la práctica, en la mayoría de los compiladores, el ejemplo que citó se ejecutará bien (a diferencia de "borrar su disco duro" y otras consecuencias teóricas de comportamiento indefinido).
Sin embargo, es una responsabilidad, ya que depende del comportamiento específico del compilador, incluso si los dos valores asignados son los mismos. Además, obviamente, si intentara asignar valores diferentes, los resultados serían "verdaderamente" indefinidos:

void f(int l, int r) { return l < -1; } auto b = f(i = -1, i = -2); if (b) { formatDisk(); }


Comúnmente, el comportamiento se especifica como indefinido si hay alguna razón concebible por la cual un compilador que intentaba ser "útil" podría hacer algo que causaría un comportamiento totalmente inesperado.

En el caso de que una variable se escriba varias veces sin nada para garantizar que las escrituras se produzcan en momentos distintos, algunos tipos de hardware podrían permitir que se realicen múltiples operaciones de "almacenamiento" de forma simultánea en diferentes direcciones utilizando una memoria de doble puerto. Sin embargo, algunas memorias de doble puerto prohíben expresamente el escenario donde dos tiendas llegan a la misma dirección simultáneamente, independientemente de si los valores escritos coinciden o no . Si un compilador para una máquina de este tipo nota dos intentos sin secuencia de escribir la misma variable, puede rechazar la compilación o asegurarse de que las dos escrituras no puedan programarse simultáneamente. Pero si uno o ambos accesos se realizan a través de un puntero o una referencia, es posible que el compilador no siempre pueda saber si ambas escrituras pueden llegar a la misma ubicación de almacenamiento. En ese caso, podría programar las escrituras simultáneamente, causando una trampa de hardware en el intento de acceso.

Por supuesto, el hecho de que alguien pueda implementar un compilador de C en una plataforma de este tipo no sugiere que tal comportamiento no deba definirse en plataformas de hardware cuando se utilizan almacenes de tipos lo suficientemente pequeños como para ser procesados ​​de forma atómica. Tratar de almacenar dos valores diferentes de manera no secuencial podría causar rarezas si un compilador no lo sabe; por ejemplo, dado:

uint8_t v; // Global void hey(uint8_t *p) { moo(v=5, (*p)=6); zoo(v); zoo(v); }

si el compilador alinea la llamada a "moo" y puede decir que no modifica "v", puede almacenar un 5 a v, luego almacenar un 6 a * p, luego pasar 5 a "zoo" y luego Pasa los contenidos de v al "zoo". Si "zoo" no modifica "v", no debería haber ninguna manera de que las dos llamadas pasen valores diferentes, pero eso podría suceder fácilmente de todos modos. Por otro lado, en los casos en que ambas tiendas escribieran el mismo valor, tal rareza no podría ocurrir y en la mayoría de las plataformas no habría una razón sensata para que una implementación haga algo extraño. Desafortunadamente, algunos escritores de compiladores no necesitan ninguna excusa para comportamientos tontos más allá de "porque el Estándar lo permite", por lo que incluso esos casos no son seguros.


Dado que las operaciones no tienen secuencia, no hay nada que diga que las instrucciones que realizan la asignación no pueden intercalarse. Podría ser óptimo hacerlo, dependiendo de la arquitectura de la CPU. La página referenciada dice esto:

Si A no está secuenciada antes de B y B no está secuenciada antes de A, entonces existen dos posibilidades:

  • las evaluaciones de A y B no tienen secuencia: pueden realizarse en cualquier orden y pueden solaparse (dentro de un solo hilo de ejecución, el compilador puede intercalar las instrucciones de la CPU que comprenden A y B)

  • las evaluaciones de A y B se secuencian de forma indeterminada: se pueden realizar en cualquier orden pero no se superponen: A se completará antes de B, o B se completará antes de A. El orden puede ser opuesto la próxima vez que la misma expresión es evaluado

Eso por sí solo no parece que causaría un problema, suponiendo que la operación que se está realizando almacena el valor -1 en una ubicación de memoria. Pero tampoco hay nada que decir que el compilador no pueda optimizar eso en un conjunto separado de instrucciones que tenga el mismo efecto, pero que podría fallar si la operación se intercalara con otra operación en la misma ubicación de memoria.

Por ejemplo, imagine que era más eficiente poner a cero la memoria, luego disminuirla, en comparación con cargar el valor -1 en. Entonces esto:

f(i=-1, i=-1)

podría convertirse:

clear i clear i decr i decr i

Ahora yo es -2.

Probablemente sea un ejemplo falso, pero es posible.


El hecho de que el resultado sea el mismo en la mayoría de las implementaciones en este caso es incidental; El orden de evaluación aún no está definido. Considere f(i = -1, i = -2) : aquí, el orden importa. La única razón por la que no importa en su ejemplo es el accidente de que ambos valores son -1 .

Dado que la expresión se especifica como una con un comportamiento indefinido, un compilador compatible malintencionadamente puede mostrar una imagen inapropiada cuando evalúa f(i = -1, i = -1) y aborta la ejecución, y aún se considera completamente correcto. Por suerte, no hay compiladores de los que tenga conocimiento.


El operador de asignación podría estar sobrecargado, en cuyo caso el orden podría importar:

struct A { bool first; A () : first (false) { } const A & operator = (int i) { first = !first; return * this; } }; void f (A a1, A a2) { // ... } // ... A i; f (i = -1, i = -1); // the argument evaluated first has ax.first == true


En realidad, hay una razón para no depender del hecho de que el compilador verificará que i tenga asignado el mismo valor dos veces, de modo que sea posible reemplazarlo con una sola asignación. ¿Y si tenemos algunas expresiones?

void g(int a, int b, int c, int n) { int i; // hey, compiler has to prove Fermat''s theorem now! f(i = 1, i = (ipow(a, n) + ipow(b, n) == ipow(c, n))); }


Esto es solo responder al "No estoy seguro de lo que podría significar" objeto escalar "además de algo como un int o un flotador".

Interpretaría el "objeto escalar" como una abreviatura de "objeto de tipo escalar", o simplemente "variable de tipo escalar". Luego, pointer , enum (constante) son de tipo escalar.

Este es un artículo de MSDN de Scalar Types .


La confusión es que almacenar un valor constante en una variable local no es una instrucción atómica en cada arquitectura en la que está diseñado para ejecutarse C. El procesador en el que se ejecuta el código importa más que el compilador en este caso. Por ejemplo, en ARM donde cada instrucción no puede llevar una constante completa de 32 bits, almacenar un int en una variable necesita más de una instrucción. Ejemplo con este pseudo código donde solo puede almacenar 8 bits a la vez y debe trabajar en un registro de 32 bits, i es un int32:

reg = 0xFF; // first instruction reg |= 0xFF00; // second reg |= 0xFF0000; // third reg |= 0xFF000000; // fourth i = reg; // last

Puedes imaginar que si el compilador quiere optimizarlo puede intercalar la misma secuencia dos veces, y no sabes qué valor se escribirá en i; Y digamos que no es muy inteligente:

reg = 0xFF; reg |= 0xFF00; reg |= 0xFF0000; reg = 0xFF; reg |= 0xFF000000; i = reg; // writes 0xFF0000FF == -16776961 reg |= 0xFF00; reg |= 0xFF0000; reg |= 0xFF000000; i = reg; // writes 0xFFFFFFFF == -1

Sin embargo, en mis pruebas, gcc es lo suficientemente amable para reconocer que el mismo valor se usa dos veces y lo genera una vez y no hace nada raro. Obtengo -1, -1, pero mi ejemplo sigue siendo válido, ya que es importante tener en cuenta que incluso una constante puede no ser tan obvia como parece.


Primero, "objeto escalar" significa un tipo como int , float o un puntero (consulte ¿Qué es un objeto escalar en C ++? ).

En segundo lugar, puede parecer más obvio que

f(++i, ++i);

Tendria comportamiento indefinido. Pero

f(i = -1, i = -1);

es menos obvio

Un ejemplo ligeramente diferente:

int i; f(i = 1, i = -1); std::cout << i << "/n";

¿Qué asignación sucedió "última", i = 1 o i = -1 ? No está definido en el estándar. Realmente, eso significa que podría tener 5 (vea la respuesta de harmic para una explicación completamente plausible de cómo debería ser este caso). O tu programa puede segfault. O reformatea tu disco duro.

Pero ahora pregunta: "¿Qué pasa con mi ejemplo? Utilicé el mismo valor ( -1 ) para ambas asignaciones. ¿Qué podría estar poco claro al respecto?"

Usted tiene razón ... excepto en la forma en que el comité de estándares de C ++ describió esto.

Si un efecto secundario en un objeto escalar no tiene secuencia en relación con otro efecto secundario en el mismo objeto escalar, el comportamiento no está definido.

Podrían haber hecho una excepción especial para su caso especial, pero no lo hicieron. (¿Y por qué deberían hacerlo? ¿Qué uso tendría alguna vez?) Por lo tanto, todavía podría tener 5 . O tu disco duro podría estar vacío. Así la respuesta a tu pregunta es:

Es un comportamiento indefinido porque no está definido cuál es el comportamiento.

(Esto merece énfasis porque muchos programadores piensan que "indefinido" significa "aleatorio" o "impredecible". No lo hace; significa que no está definido por el estándar. El comportamiento podría ser 100% coherente y aún no estar definido).

¿Podría haberse definido el comportamiento? Sí. ¿Fue definido? No. Por lo tanto, es "indefinido".

Dicho esto, "indefinido" no significa que un compilador formateará tu disco duro ... significa que podría y aún sería un compilador compatible con los estándares. Siendo realistas, estoy seguro de que g ++, Clang y MSVC harán lo que usted esperaba. Ellos simplemente no "tendrían que".

Una pregunta diferente podría ser: ¿Por qué el comité de estándares de C ++ eligió hacer que este efecto colateral no tenga secuencia? . Esa respuesta implicará historia y opiniones del comité. O ¿Qué tiene de bueno tener este efecto secundario sin secuencia en C ++? , lo que permite cualquier justificación, sea o no el razonamiento real del comité de estándares. Puede hacer esas preguntas aquí o en programmers.stackexchange.com.


Una razón práctica para no hacer una excepción a las reglas solo porque los dos valores son los mismos:

// config.h #define VALUEA 1 // defaults.h #define VALUEB 1 // prog.cpp f(i = VALUEA, i = VALUEB);

Considere el caso de que esto fue permitido.

Ahora, algunos meses después, surge la necesidad de cambiar.

#define VALUEB 2

Parece inofensivo, ¿no es así? Y sin embargo, de repente, prog.cpp ya no se compilaría. Sin embargo, creemos que la compilación no debe depender del valor de un literal.

Línea inferior: no hay excepción a la regla porque haría que la compilación exitosa dependiera del valor (más bien del tipo) de una constante.

EDITAR

@HeartWare señaló que las expresiones constantes de la forma A DIV B no están permitidas en algunos idiomas, cuando B es 0, y hacen que la compilación falle. Por lo tanto, el cambio de una constante podría causar errores de compilación en algún otro lugar. Que es, IMHO, desafortunado. Pero ciertamente es bueno restringir tales cosas a lo inevitable.


C ++ 17 define reglas de evaluación más estrictas. En particular, secuencia los argumentos de la función (aunque en orden no especificado).

N5659 §4.6:15
Las evaluaciones A y B se secuencian de forma indeterminada cuando A se secuencia antes de B o B se secuencia antes de A , pero no se especifica cual. [ Nota : las evaluaciones secuenciadas de forma indeterminada no pueden superponerse, pero cualquiera de ellas puede ejecutarse primero. - nota final ]

N5659 § 8.2.2:5
La inicialización de un parámetro, incluidos todos los cálculos de valores asociados y los efectos secundarios, se secuencia de forma indeterminada con respecto a la de cualquier otro parámetro.

Permite algunos casos que serían UB antes:

f(i = -1, i = -1); // value of i is -1 f(i = -1, i = -2); // value of i is either -1 or -2, but not specified which one