Gibt es eine Erklärung für Inline-Operatoren in "k + = c + = k + = c;"?

89

Was ist die Erklärung für das Ergebnis der folgenden Operation?

k += c += k += c;

Ich habe versucht, das Ausgabeergebnis aus dem folgenden Code zu verstehen:

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

und derzeit habe ich Probleme zu verstehen, warum das Ergebnis für "k" 80 ist. Warum funktioniert die Zuweisung von k = 40 nicht (tatsächlich sagt mir Visual Studio, dass dieser Wert nicht anderweitig verwendet wird)?

Warum ist k 80 und nicht 110?

Wenn ich die Operation aufteile:

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

das Ergebnis ist k = 110.

Ich habe versucht, durch die CIL zu schauen , aber ich bin nicht so tief in der Interpretation der generierten CIL und kann nicht ein paar Details erfahren:

 // [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
quelle
3
Sie haben ein anderes Ergebnis erhalten, weil Sie die Funktion aufgeteilt haben, k + = c + = k + = c = 80, weil die Werte von k und c in allen Summen gleich bleiben, also ist k + = c + = k + = c gleich bis 10 + 30 + 10 + 30
João Paulo Amorim
78
Interessante Übung, aber in der Praxis schreiben Sie niemals eine solche Codeverkettung, es sei denn, Sie möchten, dass Ihre Mitarbeiter Sie hassen. :)
UnhandledExcepSean
3
@AndriiKotliarov weil k + = c + = k + = c 10 + 30 + 10 + 30 ist, erhält K alle Werte und C erhält nur die letzten 3 Argumente 30 + 10 + 30 = 70
João Paulo Amorim
6
Ebenfalls lesenswert - Eric Lipperts Antwort auf Was ist der Unterschied zwischen i ++ und ++ i?
Wai Ha Lee
34
"Doktor, Doktor, es tut weh, wenn ich das tue!" "Also tu das nicht."
David Conrad

Antworten:

104

Eine Operation wie a op= b;ist äquivalent zu a = a op b;. Eine Zuweisung kann als Anweisung oder als Ausdruck verwendet werden, während sie als Ausdruck den zugewiesenen Wert ergibt. Deine Meinung ...

k += c += k += c;

... kann, da der Zuweisungsoperator rechtsassoziativ ist, auch als geschrieben werden

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

oder (erweitert)

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

Wobei während der gesamten Auswertung die alten Werte der beteiligten Variablen verwendet werden. Dies gilt insbesondere für den Wert von k(siehe meine Bewertung der IL unten und den Link Wai Ha Lee). Daher erhalten Sie nicht 70 + 40 (neuer Wert von k) = 110, sondern 70 + 10 (alter Wert von k) = 80.

Der Punkt ist , dass (nach der C # spec ) „Operanden in einem Ausdruck von links nach rechts ausgewertet“ (die Operanden die Variablen sind cund kin unserem Fall). Dies ist unabhängig von der Priorität und Assoziativität des Operators, die in diesem Fall eine Ausführungsreihenfolge von rechts nach links vorschreiben. (Siehe Kommentare zu Eric Lipperts Antwort auf dieser Seite).


Nun schauen wir uns die IL an. IL geht von einer stapelbasierten virtuellen Maschine aus, dh es werden keine Register verwendet.

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)

Der Stapel sieht jetzt so aus (von links nach rechts; die Oberseite des Stapels ist rechts)

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

Beachten Sie, dass IL_000c: dup, IL_000d: stloc.0dh die erste Zuordnung zu k , weg optimiert werden könnte. Wahrscheinlich wird dies für Variablen vom Jitter durchgeführt, wenn IL in Maschinencode konvertiert wird.

Beachten Sie auch, dass alle für die Berechnung erforderlichen Werte entweder vor einer Zuweisung auf den Stapel verschoben oder aus diesen Werten berechnet werden. Zugewiesene Werte (von stloc) werden bei dieser Auswertung nie wieder verwendet. stlocPops die Oberseite des Stapels.


Die Ausgabe des folgenden Konsolentests ist ( ReleaseModus mit aktivierten Optimierungen)

Auswerten von k (10)
Auswerten von c (30)
Auswerten von k (10)
Auswerten von c (30)
40, zugewiesen an k
70, zugewiesen an c
80, zugewiesen an 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
quelle
Sie können das Endergebnis mit den Zahlen in der Formel hinzufügen, um noch vollständiger zu werden: final is k = 10 + (30 + (10 + 30)) = 80und dieser cendgültige Wert wird in der ersten Klammer angegeben c = 30 + (10 + 30) = 70.
Franck
2
Wenn kes sich um einen lokalen Speicher handelt, wird der tote Speicher mit ziemlicher Sicherheit entfernt, wenn Optimierungen aktiviert sind, und beibehalten, wenn dies nicht der Fall ist. Eine interessante Frage ist, ob der Jitter den toten Speicher verlassen darf , wenn kes sich um ein Feld, eine Eigenschaft, einen Array-Slot usw. handelt. in der Praxis glaube ich nicht.
Eric Lippert
Ein Konsolentest im Release-Modus zeigt tatsächlich, dass ker zweimal zugewiesen wird, wenn es sich um eine Eigenschaft handelt.
Olivier Jacot-Descombes
26

Zunächst einmal sind die Antworten von Henk und Olivier richtig; Ich möchte es etwas anders erklären. Insbesondere möchte ich auf diesen Punkt eingehen, den Sie angesprochen haben. Sie haben folgende Aussagen:

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

Und Sie schließen dann fälschlicherweise, dass dies das gleiche Ergebnis wie diese Aussagen liefern sollte:

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

Es ist informativ zu sehen, wie Sie das falsch verstanden haben und wie Sie es richtig machen. Der richtige Weg, es zu brechen, ist so.

Schreiben Sie zuerst das äußerste + = um

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

Zweitens schreiben Sie das äußerste + neu. Ich hoffe, Sie stimmen zu, dass x = y + z immer dasselbe sein muss wie "y als temporär bewerten, z als temporär bewerten, temporäre Summe summieren, x die Summe zuweisen" . Lassen Sie uns das sehr deutlich machen:

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

Stellen Sie sicher, dass dies klar ist, da dies der Schritt ist, den Sie falsch verstanden haben . Wenn Sie komplexe Vorgänge in einfachere Vorgänge aufteilen, müssen Sie sicherstellen, dass Sie dies langsam und vorsichtig tun und keine Schritte überspringen . Beim Überspringen von Schritten machen wir Fehler.

OK, jetzt brechen Sie die Zuordnung zu t2 wieder langsam und vorsichtig auf.

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

Die Zuweisung weist t2 den gleichen Wert zu, der c zugewiesen ist. Nehmen wir also Folgendes an:

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

Toll. Brechen Sie nun die zweite Zeile auf:

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

Großartig, wir machen Fortschritte. Teilen Sie die Zuordnung zu t4 auf:

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

Brechen Sie nun die dritte Zeile auf:

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

Und jetzt können wir uns das Ganze ansehen:

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

Wenn wir fertig sind, ist k 80 und c ist 70.

Schauen wir uns nun an, wie dies in der IL implementiert ist:

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

Das ist ein bisschen schwierig:

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

Wir hätten das oben Gesagte als umsetzen können

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

Aber wir verwenden den "dup" -Trick, weil er den Code kürzer macht und den Jitter erleichtert, und wir erhalten das gleiche Ergebnis. Im Allgemeinen versucht der C # -Codegenerator, temporäre Elemente so kurz wie möglich auf dem Stapel zu halten. Wenn Sie es einfacher finden , die IL mit weniger Ephemerals zu folgen, drehen Optimierungen aus , und der Code - Generator wird weniger aggressiv.

Wir müssen jetzt den gleichen Trick machen, um c zu erhalten:

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.

und schlussendlich:

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

Da wir die Summe für nichts anderes brauchen, täuschen wir sie nicht. Der Stapel ist jetzt leer und wir sind am Ende der Anweisung.

Die Moral der Geschichte lautet: Wenn Sie versuchen, ein kompliziertes Programm zu verstehen, brechen Sie die Operationen immer einzeln auf . Nehmen Sie keine Abkürzungen; Sie werden dich in die Irre führen.

Eric Lippert
quelle
3
@ OlivierJacot-Descombes: Die entsprechende Linie der Spezifikation ist im Abschnitt „Operatoren“ und „Operanden in einem Ausdruck sagen sind von links nach rechts ausgewertet Zum Beispiel in. F(i) + G(i++) * H(i), Verfahren F genannt wird , um den alten Wert von i verwendet wird , dann geht das Verfahren G wird mit dem alten Wert von i aufgerufen, und schließlich wird Methode H mit dem neuen Wert von i aufgerufen . Dies ist getrennt von der Operatorpriorität und steht in keinem Zusammenhang damit. " (Hervorhebung hinzugefügt.) Ich glaube, ich habe mich geirrt, als ich sagte, dass nirgendwo "der alte Wert verwendet wird" vorkommt! Es kommt in einem Beispiel vor. Aber das normative Bit ist "von links nach rechts".
Eric Lippert
1
Dies war das fehlende Glied. Die Quintessenz ist, dass wir zwischen Operandenbewertungsreihenfolge und Operatorrang unterscheiden müssen . Die Operandenbewertung erfolgt von links nach rechts und im Fall des OP die Operatorausführung von rechts nach links.
Olivier Jacot-Descombes
4
@ OlivierJacot-Descombes: Das ist genau richtig. Vorrang und Assoziativität haben überhaupt nichts mit der Reihenfolge zu tun, in der Unterausdrücke ausgewertet werden, außer der Tatsache, dass Vorrang und Assoziativität bestimmen, wo die Unterausdrucksgrenzen liegen . Unterausdrücke werden von links nach rechts ausgewertet.
Eric Lippert
1
Hoppla, es sieht so aus, als könnten Sie Zuweisungsoperatoren nicht überladen: /
Johnny 5.
1
@ Johnny5: Das ist richtig. Aber Sie können überladen +, und dann erhalten Sie +=kostenlos, weil x += ydefiniert als x = x + yausgenommen xwird nur einmal ausgewertet. Dies gilt unabhängig davon, ob das +integriert oder benutzerdefiniert ist. Also: Überladen Sie +einen Referenztyp und sehen Sie, was passiert.
Eric Lippert
14

Es läuft darauf hinaus: Wird das allererste +=auf das Original koder auf den Wert angewendet, der mehr rechts berechnet wurde?

Die Antwort lautet: Obwohl die Zuweisungen von rechts nach links gebunden sind, werden die Vorgänge immer noch von links nach rechts ausgeführt.

Das am weitesten links stehende +=wird also ausgeführt 10 += 70.

Henk Holterman
quelle
1
Dies bringt es schön in eine Nussschale.
Aganju
Es sind eigentlich die Operanden, die von links nach rechts ausgewertet werden.
Olivier Jacot-Descombes
0

Ich habe das Beispiel mit gcc und pgcc ausprobiert und 110 erhalten. Ich habe die von ihnen erzeugte IR überprüft, und der Compiler hat den Ausdruck erweitert auf:

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

das sieht für mich vernünftig aus.

Brian Yang
quelle
-1

Für diese Art von Kettenzuweisungen müssen Sie die Werte von der rechten Seite aus zuweisen. Sie müssen es zuweisen und berechnen und der linken Seite zuweisen und dies bis zum Ende (Zuordnung ganz links) fortsetzen. Sicher, es wird als k = 80 berechnet.

Hasan Zeki Alp
quelle
Bitte posten Sie keine Antworten, die einfach erneut angeben, was zahlreiche andere Antworten bereits angeben.
Eric Lippert
-1

Einfache Antwort: Ersetzen Sie vars durch Werte und Sie haben es:

int k = 10;
int c = 30;
k += c += k += c;
10 += 30 += 10 += 30
= 10 + 30 + 10 + 30
= 80 !!!
Thomas Michael
quelle
Diese Antwort ist falsch. Obwohl diese Technik in diesem speziellen Fall funktioniert, funktioniert dieser Algorithmus im Allgemeinen nicht. Zum Beispiel heißt k = 10; m = (k += k) + k;das nicht m = (10 + 10) + 10. Sprachen mit mutierenden Ausdrücken können nicht so analysiert werden, als hätten sie eine eifrige Wertersubstitution . Die Wertsubstitution erfolgt in einer bestimmten Reihenfolge in Bezug auf die Mutationen, und Sie müssen dies berücksichtigen.
Eric Lippert
-1

Sie können dies durch Zählen lösen.

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

Es gibt also zwei cs und zwei ks

a = 2c + 2k

Und als Folge der Operatoren der Sprache ist kauch gleich2c + 2k

Dies funktioniert für jede Kombination von Variablen in diesem Kettenstil:

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

So

a = 2m + n + 3r

Und rwird gleich gleich sein.

Sie können die Werte der anderen Zahlen berechnen, indem Sie nur bis zu ihrer Zuordnung ganz links berechnen. Also mgleich 2m + nund ngleich n + m.

Dies zeigt, dass dies k += c += k += c;anders ist k += c; c += k; k += c;und daher unterschiedliche Antworten erhalten.

Einige Leute in den Kommentaren scheinen besorgt zu sein, dass Sie versuchen könnten, diese Verknüpfung auf alle möglichen Arten von Hinzufügungen zu verallgemeinern. Ich werde also klarstellen, dass diese Verknüpfung nur auf diese Situation anwendbar ist, dh Additionszuweisungen für die eingebauten Nummerntypen miteinander verketten. Es funktioniert (nicht unbedingt), wenn Sie andere Operatoren hinzufügen, z. B. ()oder +, oder wenn Sie Funktionen aufrufen oder wenn Sie überschrieben +=haben oder wenn Sie etwas anderes als die grundlegenden Nummerntypen verwenden. Es soll nur bei der jeweiligen Situation in der Frage helfen .

Matt Ellen
quelle
Dies beantwortet nicht die Frage
Johnny 5
@ johnny5 es erklärt, warum Sie das Ergebnis erhalten, das Sie erhalten, dh weil so Mathematik funktioniert.
Matt Ellen
2
Mathematik und die Reihenfolge der Operationen, die ein Compiler einer Anweisung entzieht, sind zwei verschiedene Dinge. Nach Ihrer Logik k + = c; c + = k; k + = c sollte zum gleichen Ergebnis führen.
Johnny 5.
Nein, Johnny 5, das bedeutet es nicht. Mathematisch sind sie verschiedene Dinge. Die drei getrennten Operationen ergeben 3c + 2k.
Matt Ellen
2
Leider ist Ihre "algebraische" Lösung nur zufällig richtig. Ihre Technik funktioniert im Allgemeinen nicht . Bedenken Sie x = 1;und y = (x += x) + x;ist es Ihre Behauptung, dass "es drei x gibt und y gleich ist 3 * x"? Denn ygleich 4in diesem Fall. Was y = x + (x += x);ist nun Ihre Behauptung, dass das algebraische Gesetz "a + b = b + a" erfüllt ist und dies auch 4 ist? Weil dies 3 ist. Leider folgt C # nicht den Regeln der High-School-Algebra, wenn die Ausdrücke Nebenwirkungen haben . C # folgt den Regeln einer Nebenwirkung der Algebra.
Eric Lippert