¿Existe una explicación para los operadores en línea en "k + = c + = k + = c;"?

89

¿Cuál es la explicación del resultado de la siguiente operación?

k += c += k += c;

Estaba tratando de comprender el resultado de salida del siguiente código:

int k = 10;
int c = 30;
k += c += k += c;
//k=80 instead of 110
//c=70

y actualmente estoy luchando por entender por qué el resultado de "k" es 80. ¿Por qué la asignación de k = 40 no funciona (en realidad, Visual Studio me dice que ese valor no se está usando en ningún otro lugar)?

¿Por qué k es 80 y no 110?

Si divido la operación en:

k+=c;
c+=k;
k+=c;

el resultado es k = 110.

Estaba tratando de mirar a través del CIL , pero no soy tan profundo en la interpretación del CIL generado y no puedo obtener algunos detalles:

 // [11 13 - 11 24]
IL_0001: ldc.i4.s     10
IL_0003: stloc.0      // k

// [12 13 - 12 24]
IL_0004: ldc.i4.s     30
IL_0006: stloc.1      // c

// [13 13 - 13 30]
IL_0007: ldloc.0      // k expect to be 10
IL_0008: ldloc.1      // c
IL_0009: ldloc.0      // k why do we need the second load?
IL_000a: ldloc.1      // c
IL_000b: add          // I expect it to be 40
IL_000c: dup          // What for?
IL_000d: stloc.0      // k - expected to be 40
IL_000e: add
IL_000f: dup          // I presume the "magic" happens here
IL_0010: stloc.1      // c = 70
IL_0011: add
IL_0012: stloc.0      // k = 80??????
Andrii Kotliarov
fuente
3
Obtuviste un resultado diferente porque dividiste la función, k + = c + = k + = c = 80 porque los valores de k y c permanecen iguales en todas las sumas, entonces k + = c + = k + = c es igual al 10 + 30 + 10 + 30
João Paulo Amorim
78
Es un ejercicio interesante, pero, en la práctica, nunca escriba encadenamiento de código como ese a menos que quiera que sus compañeros de trabajo lo odien. :)
UnhandledExcepSean
3
@AndriiKotliarov porque k + = c + = k + = c es 10 + 30 + 10 + 30, entonces, K recibe todos los valores y C obtiene solo los últimos 3 argumentos 30 + 10 + 30 = 70
João Paulo Amorim
6
También vale la pena leer: la respuesta de Eric Lippert a ¿Cuál es la diferencia entre i ++ y ++ i?
Wai Ha Lee
34
"¡Doctor, doctor, me duele cuando hago esto!" "Así que no HAGAS eso".
David Conrad

Respuestas:

104

Una operación como a op= b;es equivalente a a = a op b;. Una asignación puede usarse como declaración o como expresión, mientras que como expresión da el valor asignado. Tu declaración ...

k += c += k += c;

... puede, dado que el operador de asignación es asociativo a la derecha, también puede escribirse como

k += (c += (k += c));

o (expandido)

k =  k +  (c = c +  (k = k  + c));
     10301030   // operand evaluation order is from left to right
      |         |        ↓    ↓
      |         ↓   4010 + 30   // operator evaluation7030 + 40
8010 + 70

Donde durante toda la evaluación se utilizan los valores antiguos de las variables involucradas. Esto es especialmente cierto por el valor de k(vea mi revisión del IL a continuación y el enlace que proporcionó Wai Ha Lee). Por lo tanto, no obtiene 70 + 40 (nuevo valor de k) = 110, sino 70 + 10 (antiguo valor de k) = 80.

El punto es que (de acuerdo con la especificación de C # ) "los operandos en una expresión se evalúan de izquierda a derecha" (los operandos son las variables cy ken nuestro caso). Esto es independiente de la precedencia del operador y la asociatividad que en este caso dictan un orden de ejecución de derecha a izquierda. (Vea los comentarios a la respuesta de Eric Lippert en esta página).


Ahora miremos el IL. IL asume una máquina virtual basada en pila, es decir, no usa registros.

IL_0007: ldloc.0      // k (is 10)
IL_0008: ldloc.1      // c (is 30)
IL_0009: ldloc.0      // k (is 10)
IL_000a: ldloc.1      // c (is 30)

La pila ahora se ve así (de izquierda a derecha; la parte superior de la pila está a la derecha)

10 30 10 30

IL_000b: add          // pops the 2 top (right) positions, adds them and pushes the sum back

10 30 40

IL_000c: dup

10 30 40 40

IL_000d: stloc.0      // k <-- 40

10 30 40

IL_000e: add

10 70

IL_000f: dup

10 70 70

IL_0010: stloc.1      // c <-- 70

10 70

IL_0011: add

80

IL_0012: stloc.0      // k <-- 80

Tenga en cuenta que IL_000c: dup , IL_000d: stloc.0es decir, la primera asignación a k , podría optimizarse. Probablemente esto se haga para las variables por el jitter al convertir IL a código de máquina.

Tenga en cuenta también que todos los valores requeridos por el cálculo se envían a la pila antes de realizar cualquier asignación o se calculan a partir de estos valores. Valores asignados (porstloc ) nunca se reutilizan durante esta evaluación. stlocaparece la parte superior de la pila.


El resultado de la siguiente prueba de consola es (Release modo con optimizaciones activadas)

evaluar k (10)
evaluar c (30)
evaluar k (10)
evaluar c (30)
40 asignado a k
70 asignado a c
80 asignado a k

private static int _k = 10;
public static int k
{
    get { Console.WriteLine($"evaluating k ({_k})"); return _k; }
    set { Console.WriteLine($"{value} assigned to k"); _k = value; }
}

private static int _c = 30;
public static int c
{
    get { Console.WriteLine($"evaluating c ({_c})"); return _c; }
    set { Console.WriteLine($"{value} assigned to c"); _c = value; }
}

public static void Test()
{
    k += c += k += c;
}
Olivier Jacot-Descombes
fuente
Puede agregar el resultado final con los números en la fórmula para que sea aún más completo: final es k = 10 + (30 + (10 + 30)) = 80y ese cvalor final se establece en el primer paréntesis que es c = 30 + (10 + 30) = 70.
Franck
2
De hecho, si kes un local, es casi seguro que la tienda muerta se elimina si las optimizaciones están activadas y se conserva si no lo están. Una pregunta interesante es si se permite que el jitter elide el almacén muerto si kes un campo, propiedad, ranura de matriz, etc. en la práctica, creo que no.
Eric Lippert
De hecho, una prueba de consola en modo Release muestra que kse asigna dos veces si es una propiedad.
Olivier Jacot-Descombes
26

En primer lugar, las respuestas de Henk y Olivier son correctas; Quiero explicarlo de una manera ligeramente diferente. Específicamente, quiero abordar este punto que hizo. Tienes este conjunto de declaraciones:

int k = 10;
int c = 30;
k += c += k += c;

Y luego concluye incorrectamente que esto debería dar el mismo resultado que este conjunto de declaraciones:

int k = 10;
int c = 30;
k += c;
c += k;
k += c;

Es informativo ver cómo se equivocó y cómo hacerlo bien. La forma correcta de descomponerlo es así.

Primero, reescribe el más externo + =

k = k + (c += k += c);

En segundo lugar, reescribe el + más externo. Espero que esté de acuerdo en que x = y + z siempre debe ser lo mismo que "evaluar y como temporal, evaluar z como temporal, sumar los temporales, asignar la suma ax" . Así que hagámoslo muy explícito:

int t1 = k;
int t2 = (c += k += c);
k = t1 + t2;

Asegúrese de que esté claro, porque este es el paso que se equivocó . Al dividir operaciones complejas en operaciones más simples, debe asegurarse de hacerlo lenta y cuidadosamente y de no omitir pasos . Saltar pasos es donde cometemos errores.

Bien, ahora divida la asignación en t2, nuevamente, lenta y cuidadosamente.

int t1 = k;
int t2 = (c = c + (k += c));
k = t1 + t2;

La asignación asignará el mismo valor a t2 que a c, así que digamos que:

int t1 = k;
int t2 = c + (k += c);
c = t2;
k = t1 + t2;

Excelente. Ahora analice la segunda línea:

int t1 = k;
int t3 = c;
int t4 = (k += c);
int t2 = t3 + t4;
c = t2;
k = t1 + t2;

Genial, estamos progresando. Divida la asignación en t4:

int t1 = k;
int t3 = c;
int t4 = (k = k + c);
int t2 = t3 + t4;
c = t2;
k = t1 + t2;

Ahora analice la tercera línea:

int t1 = k;
int t3 = c;
int t4 = k + c;
k = t4;
int t2 = t3 + t4;
c = t2;
k = t1 + t2;

Y ahora podemos ver todo:

int k = 10;  // 10
int c = 30;  // 30
int t1 = k;  // 10
int t3 = c;  // 30
int t4 = k + c; // 40
k = t4;         // 40
int t2 = t3 + t4; // 70
c = t2;           // 70
k = t1 + t2;      // 80

Entonces, cuando terminamos, k es 80 y c es 70.

Ahora veamos cómo se implementa esto en IL:

int t1 = k;
int t3 = c;  
  is implemented as
ldloc.0      // stack slot 1 is t1
ldloc.1      // stack slot 2 is t3

Ahora bien, esto es un poco complicado:

int t4 = k + c; 
k = t4;         
  is implemented as
ldloc.0      // load k
ldloc.1      // load c
add          // sum them to stack slot 3
dup          // t4 is stack slot 3, and is now equal to the sum
stloc.0      // k is now also equal to the sum

Podríamos haber implementado lo anterior como

ldloc.0      // load k
ldloc.1      // load c
add          // sum them
stloc.0      // k is now equal to the sum
ldloc.0      // t4 is now equal to k

pero usamos el truco "dup" porque acorta el código y facilita el jitter, y obtenemos el mismo resultado. En general, el generador de código C # intenta mantener los temporales "efímeros" en la pila tanto como sea posible. Si le resulta más fácil seguir la IL con menos efímeras, a su vez optimizaciones fuera , y el generador de código será menos agresivo.

Ahora tenemos que hacer el mismo truco para obtener c:

int t2 = t3 + t4; // 70
c = t2;           // 70
  is implemented as:
add          // t3 and t4 are the top of the stack.
dup          
stloc.1      // again, we do the dup trick to get the sum in 
             // both c and t2, which is stack slot 2.

y finalmente:

k = t1 + t2;
  is implemented as
add          // stack slots 1 and 2 are t1 and t2.
stloc.0      // Store the sum to k.

Como no necesitamos la suma para nada más, no la duplicamos. La pila ahora está vacía y estamos al final de la declaración.

La moraleja de la historia es: cuando intente comprender un programa complicado, siempre analice las operaciones una por una . No tome atajos; te llevarán por mal camino.

Eric Lippert
fuente
3
@ OlivierJacot-Descombes: La línea relevante de la especificación está en la sección "Operadores" y dice "Los operandos en una expresión se evalúan de izquierda a derecha. Por ejemplo, en F(i) + G(i++) * H(i), se llama al método F usando el valor anterior de i, luego al método G se llama con el antiguo valor de i y, finalmente, el método H se llama con el nuevo valor de i . Esto es independiente y no está relacionado con la precedencia del operador ". (Énfasis agregado.) ¡Así que supongo que estaba equivocado cuando dije que no hay ningún lugar donde ocurra "el valor anterior"! Ocurre en un ejemplo. Pero el bit normativo es "de izquierda a derecha".
Eric Lippert
1
Este era el eslabón perdido. La quintaesencia es que debemos diferenciar entre el orden de evaluación del operando y la precedencia del operador . La evaluación del operando va de izquierda a derecha y en el caso del OP la ejecución del operador de derecha a izquierda.
Olivier Jacot-Descombes
4
@ OlivierJacot-Descombes: Eso es exactamente correcto. La precedencia y la asociatividad no tienen nada que ver con el orden en que se evalúan las subexpresiones, salvo el hecho de que la precedencia y la asociatividad determinan dónde están los límites de las subexpresiones . Las subexpresiones se evalúan de izquierda a derecha.
Eric Lippert
1
Vaya, parece que no puede sobrecargar los operadores de asignación: /
johnny 5
1
@ johnny5: Eso es correcto. Pero puede sobrecargar +, y luego lo obtendrá +=gratis porque x += yse define como x = x + yexcepto que xse evalúa solo una vez. Eso es cierto independientemente de si +está integrado o definido por el usuario. Entonces: intente sobrecargar +un tipo de referencia y vea qué sucede.
Eric Lippert
14

Se reduce a: ¿se +=aplica lo primero al original ko al valor que se calculó más a la derecha?

La respuesta es que, aunque las asignaciones se vinculan de derecha a izquierda, las operaciones siguen avanzando de izquierda a derecha.

Entonces el de la izquierda +=está ejecutando 10 += 70.

Henk Holterman
fuente
1
Esto lo pone muy bien en una cáscara de nuez.
Aganju
En realidad, son los operandos los que se evalúan de izquierda a derecha.
Olivier Jacot-Descombes
0

Probé el ejemplo con gcc y pgcc y obtuve 110. Verifiqué el IR que generaron y el compilador expandió el expr a:

k = 10;
c = 30;
k = c+k;
c = c+k;
k = c+k;

que me parece razonable.

Brian Yang
fuente
-1

para este tipo de asignaciones en cadena, debe asignar los valores comenzando en el lado más a la derecha. Tienes que asignarlo y calcularlo y asignarlo al lado izquierdo, y continuar con esto hasta la final (asignación más a la izquierda). Seguro que se calcula como k = 80.

Hasan Zeki Alp
fuente
No publique respuestas que simplemente reafirmen lo que ya afirman muchas otras respuestas.
Eric Lippert
-1

Respuesta simple: Reemplaza vars con valores y lo tienes:

int k = 10;
int c = 30;
k += c += k += c;
10 += 30 += 10 += 30
= 10 + 30 + 10 + 30
= 80 !!!
Thomas Michael
fuente
Esta respuesta es incorrecta. Aunque esta técnica funciona en este caso específico, ese algoritmo no funciona en general. Por ejemplo, k = 10; m = (k += k) + k;no significa m = (10 + 10) + 10. Los lenguajes con expresiones mutantes no pueden analizarse como si tuvieran una ansiosa sustitución de valor . La sustitución de valores ocurre en un orden particular con respecto a las mutaciones y debes tener eso en cuenta.
Eric Lippert
-1

Puedes resolver esto contando.

a = k += c += k += c

Hay dos csy dos ks así que

a = 2c + 2k

Y, como consecuencia de los operadores del lenguaje, ktambién es igual a2c + 2k

Esto funcionará para cualquier combinación de variables en este estilo de cadena:

a = r += r += r += m += n += m

Entonces

a = 2m + n + 3r

Y rserá igual a lo mismo.

Puede calcular los valores de los otros números calculando solo hasta su asignación más a la izquierda. Entonces mes igual 2m + ny nes igual n + m.

Esto demuestra que k += c += k += c;es diferente k += c; c += k; k += c;y, por lo tanto, obtiene respuestas diferentes.

Algunas personas en los comentarios parecen estar preocupadas de que pueda intentar generalizar en exceso desde este atajo a todos los tipos posibles de adición. Entonces, dejaré en claro que este atajo solo es aplicable a esta situación, es decir, encadenar asignaciones de suma para los tipos de números integrados. No funciona (necesariamente) si agrega otros operadores en, por ejemplo, ()o +, o si llama a funciones o si ha anulado +=, o si está usando algo diferente a los tipos de números básicos. Solo está destinado a ayudar con la situación particular en la pregunta .

Matt Ellen
fuente
Esto no responde a la pregunta
johnny 5
@ johnny5 explica por qué obtienes el resultado que obtienes, es decir, porque así es como funcionan las matemáticas.
Matt Ellen
2
Las matemáticas y los órdenes de operaciones que un compilador evalúa en una declaración son dos cosas diferentes. Bajo su lógica k + = c; c + = k; k + = c debería evaluar el mismo resultado.
johnny 5
No, Johnny 5, eso no es lo que significa. Matemáticamente son cosas diferentes. Las tres operaciones separadas se evalúan a 3c + 2k.
Matt Ellen
2
Desafortunadamente, su solución "algebraica" es sólo coincidentemente correcta. Tu técnica no funciona en general . Considere x = 1;y y = (x += x) + x;¿es su opinión que "hay tres x y, por lo tanto, y es igual a 3 * x"? Porque yes igual a 4en este caso. Ahora bien, ¿qué pasa y = x + (x += x);con su opinión de que la ley algebraica "a + b = b + a" se cumple y esto también es 4? Porque esto es 3. Desafortunadamente, C # no sigue las reglas del álgebra de la escuela secundaria si hay efectos secundarios en las expresiones . C # sigue las reglas de un álgebra de efectos secundarios.
Eric Lippert