Existe uma explicação para os operadores inline em “k + = c + = k + = c;”?

89

Qual é a explicação para o resultado da operação a seguir?

k += c += k += c;

Eu estava tentando entender o resultado de saída do seguinte código:

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

e atualmente estou lutando para entender por que o resultado para "k" é 80. Por que a atribuição de k = 40 não está funcionando (na verdade, o Visual Studio me diz que esse valor não está sendo usado em outro lugar)?

Por que k é 80 e não 110?

Se eu dividir a operação em:

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

o resultado é k = 110.

Eu estava tentando examinar o CIL , mas não sou tão profundo em interpretar o CIL gerado e não consigo obter alguns detalhes:

 // [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
fonte
3
Você obteve resultados diferentes porque dividiu a função, k + = c + = k + = c = 80 porque os valores de k e c permanecem os mesmos em todas as somas, então k + = c + = k + = c é igual a 10 + 30 + 10 + 30
João Paulo Amorim
78
Exercício interessante, mas, na prática, nunca escreva encadeamento de código como esse, a menos que queira que seus colegas de trabalho o odeiem. :)
UnhandledExcepSean
3
@AndriiKotliarov porque k + = c + = k + = c é 10 + 30 + 10 + 30, então, K recebe todos os valores, e C recebe apenas os últimos 3 argumentos 30 + 10 + 30 = 70
João Paulo Amorim
6
Também vale a pena ler - a resposta de Eric Lippert para Qual é a diferença entre i ++ e ++ i?
Wai Ha Lee
34
"Doutor, doutor, dói quando eu faço isso!" "Portanto, não faça isso."
David Conrad

Respostas:

104

Uma operação like a op= b;é equivalente a a = a op b;. Uma atribuição pode ser usada como declaração ou expressão, enquanto como expressão ela produz o valor atribuído. Sua declaração ...

k += c += k += c;

... pode, uma vez que o operador de atribuição é associativo à direita, também ser escrito como

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

ou (expandido)

k =  k +  (c = c +  (k = k  + c));
     10       30       10  30   // operand evaluation order is from left to right
      |         |            
      |            40  10 + 30   // operator evaluation
         70  30 + 40
80  10 + 70

Onde durante toda a avaliação são usados ​​os valores antigos das variáveis ​​envolvidas. Isso é especialmente verdadeiro para o valor de k(veja minha análise do IL abaixo e o link fornecido por Wai Ha Lee). Portanto, você não está obtendo 70 + 40 (novo valor de k) = 110, mas 70 + 10 (valor antigo de k) = 80.

A questão é que (de acordo com a especificação C # ) "Operandos em uma expressão são avaliados da esquerda para a direita" (os operandos são as variáveis ce, kem nosso caso). Isso é independente da precedência do operador e da associatividade que, neste caso, ditam uma ordem de execução da direita para a esquerda. (Veja os comentários à resposta de Eric Lippert nesta página).


Agora vamos dar uma olhada no IL. IL assume uma máquina virtual baseada em pilha, ou seja, não usa registradores.

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)

A pilha agora se parece com isto (da esquerda para a direita; topo da pilha está à direita)

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

Observe que IL_000c: dup, IL_000d: stloc.0isto é, a primeira atribuição a k , pode ser otimizada. Provavelmente, isso é feito para variáveis ​​pelo jitter ao converter IL em código de máquina.

Observe também que todos os valores exigidos pelo cálculo são colocados na pilha antes de qualquer atribuição ser feita ou são calculados a partir desses valores. Os valores atribuídos (por stloc) nunca são reutilizados durante esta avaliação. stlocaparece no topo da pilha.


A saída do seguinte teste de console é ( Releasemodo com otimizações ativadas)

avaliando k (10)
avaliando c (30)
avaliando k (10)
avaliando c (30)
40 atribuído a k
70 atribuído a c
80 atribuído 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
fonte
Você pode adicionar o resultado final com os números na fórmula para ainda mais completo: final é k = 10 + (30 + (10 + 30)) = 80e esse cvalor final é definido no primeiro parêntese que é c = 30 + (10 + 30) = 70.
Franck de
2
Na verdade, se kfor um local, o armazenamento morto é quase certamente removido se as otimizações estiverem ativadas e preservado se não estiverem. Uma questão interessante é se o jitter tem permissão para omitir o armazenamento morto se kfor um campo, propriedade, slot de array e assim por diante; na prática, acredito que não.
Eric Lippert
Um teste de console no modo Release mostra de fato que ké atribuído duas vezes se for uma propriedade.
Olivier Jacot-Descombes
26

Em primeiro lugar, as respostas de Henk e Olivier estão corretas; Quero explicar de uma maneira ligeiramente diferente. Especificamente, quero abordar esse ponto que você fez. Você tem este conjunto de afirmações:

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

E então você conclui incorretamente que isso deve dar o mesmo resultado que este conjunto de afirmações:

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

É informativo saber como você errou e como fazer da maneira certa. A maneira certa de decompô-lo é assim.

Primeiro, reescreva o mais externo + =

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

Em segundo lugar, reescrever o + externo. Espero que você concorde que x = y + z deve ser sempre o mesmo que "avalie y para um temporário, avalie z para um temporário, some os temporários, atribua a soma a x" . Então, vamos deixar isso bem explícito:

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

Certifique-se de que está claro, porque essa é a etapa que você errou . Ao dividir operações complexas em operações mais simples, você deve certificar-se de fazê-lo lenta e cuidadosamente e não pular etapas . Pular etapas é onde cometemos erros.

OK, agora divida a atribuição para t2, de novo, lenta e cuidadosamente.

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

A atribuição atribuirá a t2 o mesmo valor atribuído a c, então digamos que:

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

Ótimo. Agora divida a segunda linha:

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

Ótimo, estamos progredindo. Divida a atribuição para t4:

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

Agora divida a terceira linha:

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

E agora podemos olhar para tudo:

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

Então, quando terminarmos, k é 80 e c é 70.

Agora, vamos ver como isso é implementado no IL:

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

Agora, isso é um pouco 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

Poderíamos ter implementado o acima 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

mas usamos o truque "dup" porque torna o código mais curto e mais fácil no jitter, e obtemos o mesmo resultado. Em geral, o gerador de código C # tenta manter os temporários "efêmeros" na pilha tanto quanto possível. Se você achar que é mais fácil de seguir do IL com menos Ephemerals, vire otimizações off , e o gerador de código será menos agressiva.

Agora temos que fazer o mesmo truque para obter 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.

e 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 não precisamos da soma para mais nada, não a enganamos. A pilha agora está vazia e estamos no final da instrução.

A moral da história é: quando você está tentando entender um programa complicado, sempre divida as operações uma de cada vez . Não tome atalhos; eles o desviarão.

Eric Lippert
fonte
3
@ OlivierJacot-Descombes: A linha relevante da especificação está na seção "Operadores" e diz "Operandos em uma expressão são avaliados da esquerda para a direita. Por exemplo, em F(i) + G(i++) * H(i), o método F é chamado usando o valor antigo de i, então o método G é chamado com o valor antigo de i e, finalmente, o método H é chamado com o novo valor de i . Isso é separado e não relacionado à precedência do operador. " (Ênfase adicionada). Portanto, acho que estava errado quando disse que não há nenhum lugar onde "o valor antigo seja usado" ocorre! Isso ocorre em um exemplo. Mas a parte normativa é "da esquerda para a direita".
Eric Lippert
1
Este era o elo que faltava. A quintessência é que devemos diferenciar entre a ordem de avaliação do operando e a precedência do operador . A avaliação do operando vai da esquerda para a direita e no caso do OP a execução do operador da direita para a esquerda.
Olivier Jacot-Descombes
4
@ OlivierJacot-Descombes: Exatamente. A precedência e a associatividade nada têm a ver com a ordem em que as subexpressões são avaliadas, a não ser o fato de que a precedência e a associatividade determinam onde estão os limites da subexpressão . As subexpressões são avaliadas da esquerda para a direita.
Eric Lippert
1
Opa, parece que você não pode sobrecarregar os operadores de atribuição: /
johnny 5 de
1
@ johnny5: Correto. Mas você pode sobrecarregar +, e então você obterá +=gratuitamente porque x += yé definido como x = x + yexceto xé avaliado apenas uma vez. Isso é verdade independentemente de o +ser integrado ou definido pelo usuário. Portanto: tente sobrecarregar +em um tipo de referência e veja o que acontece.
Eric Lippert de
14

Tudo se resume a: o primeiro é +=aplicado ao original kou ao valor que foi calculado mais à direita?

A resposta é que, embora as atribuições sejam vinculadas da direita para a esquerda, as operações continuam da esquerda para a direita.

Portanto, o mais à esquerda +=está executando 10 += 70.

Henk Holterman
fonte
1
Isso o coloca muito bem em uma casca de noz.
Aganju
Na verdade, são os operandos que são avaliados da esquerda para a direita.
Olivier Jacot-Descombes
0

Tentei o exemplo com gcc e pgcc e obtive 110. Verifiquei o IR que eles geraram e o compilador expandiu a expr para:

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

o que parece razoável para mim.

Brian Yang
fonte
-1

para este tipo de atribuições em cadeia, você deve atribuir os valores começando pelo lado direito. Você tem que atribuir, calcular e atribuir ao lado esquerdo, e prosseguir até o final (atribuição mais à esquerda). Claro que é calculado como k = 80.

Hasan Zeki Alp
fonte
Não publique respostas que simplesmente reafirmem o que várias outras respostas já afirmam.
Eric Lippert de
-1

Resposta simples: substitua vars por valores e você entendeu:

int k = 10;
int c = 30;
k += c += k += c;
10 += 30 += 10 += 30
= 10 + 30 + 10 + 30
= 80 !!!
Thomas Michael
fonte
Esta resposta está errada. Embora essa técnica funcione neste caso específico, esse algoritmo não funciona em geral. Por exemplo, k = 10; m = (k += k) + k;não significa m = (10 + 10) + 10. Linguagens com expressões mutantes não podem ser analisadas como se tivessem uma rápida substituição de valor . A substituição de valor ocorre em uma ordem específica em relação às mutações e você deve levar isso em consideração.
Eric Lippert de
-1

Você pode resolver isso contando.

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

Existem dois se cdois ks então

a = 2c + 2k

E, como consequência dos operadores da linguagem, ktambém é igual2c + 2k

Isso funcionará para qualquer combinação de variáveis ​​neste estilo de cadeia:

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

assim

a = 2m + n + 3r

E rserá igual ao mesmo.

Você pode calcular os valores dos outros números calculando apenas até sua atribuição mais à esquerda. Portanto, miguais 2m + ne niguais n + m.

Isso demonstra que k += c += k += c;é diferente k += c; c += k; k += c;e, portanto, por que você obtém respostas diferentes.

Algumas pessoas nos comentários parecem estar preocupadas com a possibilidade de você tentar generalizar demais a partir desse atalho para todos os tipos possíveis de adição. Portanto, deixarei claro que este atalho só se aplica a esta situação, ou seja, encadear atribuições de adição para os tipos de número integrados. Não funciona (necessariamente) se você adicionar outros operadores em, por exemplo, ()ou +, ou se você chamar funções ou se tiver substituído +=, ou se estiver usando algo diferente dos tipos básicos de número. Destina-se apenas a ajudar na situação particular da questão .

Matt Ellen
fonte
Isso não responde à pergunta
johnny 5 de
@ johnny5 explica porque você obtém o resultado que obtém, ou seja, porque é assim que a matemática funciona.
Matt Ellen de
2
Matemática e as ordens de operações que um compilador avalia uma instrução são duas coisas diferentes. Sob sua lógica k + = c; c + = k; k + = c deve resultar no mesmo resultado.
johnny 5 de
Não, johnny 5, não é isso que significa. Matematicamente, são coisas diferentes. As três operações separadas avaliam 3c + 2k.
Matt Ellen de
2
Infelizmente, sua solução "algébrica" ​​é apenas coincidentemente correta. Sua técnica não funciona em geral . Considere x = 1;e y = (x += x) + x;é sua afirmação que "há três x e, portanto, y é igual a 3 * x"? Porque yé igual a 4neste caso. Agora, o y = x + (x += x);que você acha de que a lei algébrica "a + b = b + a" é cumprida e isso também é 4? Porque este é 3. Infelizmente, C # não segue as regras da álgebra do ensino médio se houver efeitos colaterais nas expressões . C # segue as regras de uma álgebra de efeito colateral.
Eric Lippert de