Warum sollten verschiedene Compiler in der Praxis unterschiedliche Werte von int x = ++ i + ++ i; berechnen?

164

Betrachten Sie diesen Code:

int i = 1;
int x = ++i + ++i;

Wir haben einige Vermutungen, was ein Compiler für diesen Code tun könnte, vorausgesetzt, er wird kompiliert.

  1. beide ++ikehren zurück 2, was zu x=4.
  2. einer ++ikehrt zurück 2und der andere kehrt zurück 3, was zu x=5.
  3. beide ++ikehren zurück 3, was zu x=6.

Der zweite scheint mir am wahrscheinlichsten. Einer der beiden ++Operatoren wird mit ausgeführt i = 1, der iwird inkrementiert und das Ergebnis 2zurückgegeben. Dann wird der zweite ++Operator mit ausgeführt i = 2, der iwird inkrementiert und das Ergebnis 3zurückgegeben. Dann 2und 3werden addiert, um zu geben 5.

Ich habe diesen Code jedoch in Visual Studio ausgeführt und das Ergebnis war 6. Ich versuche, Compiler besser zu verstehen, und ich frage mich, was möglicherweise zu einem Ergebnis führen könnte 6. Ich vermute nur, dass der Code mit einer "eingebauten" Parallelität ausgeführt werden könnte. Die beiden ++Operatoren wurden aufgerufen, wobei jeder inkrementiert wurde, ibevor der andere zurückkehrte, und dann kehrten beide zurück 3. Dies würde meinem Verständnis des Aufrufstapels widersprechen und müsste weg erklärt werden.

Welche (vernünftigen) Dinge könnte ein C++Compiler tun, die zu einem Ergebnis 4oder einem Ergebnis führen würden oder 6?

Hinweis

Dieses Beispiel erschien als Beispiel für undefiniertes Verhalten in Bjarne Stroustrups Programmierung: Prinzipien und Praxis mit C ++ (C ++ 14).

Siehe Zimt Kommentar .

Zimt
quelle
5
Die C-Spezifikation deckt nicht die Reihenfolge der Operationen oder Auswertungen auf der rechten Seite des = im Vergleich zu den Operationen vor / nach dem Inkrementieren ab, sondern nur auf der linken Seite.
Cristobol Polychronopolis
2
Empfehlen Sie, dass Sie in der Frage ein Zitat angeben, wenn Sie dieses Beispiel aus einem Buch von Stroustrup erhalten haben (wie im Kommentar zu einer der Antworten erwähnt).
Daniel R. Collins
4
@philipxy Ihr vorgeschlagenes Duplikat ist kein Duplikat dieser Frage. Die Fragen sind unterschiedlich. Die Antworten in Ihrem vorgeschlagenen Duplikat beantworten diese Frage nicht. Die Antworten in Ihrem vorgeschlagenen Duplikat sind keine Duplikate der akzeptierten (oder hochstimmigen) Antwort (en) auf diese Frage. Ich glaube, Sie haben meine Frage falsch verstanden. Ich schlage vor, Sie lesen es noch einmal und überdenken die Abstimmung zum Abschluss.
Zimt
3
@philipxy "Die Antworten besagen, dass ein Compiler alles kann ..." Das beantwortet meine Frage nicht. "Sie zeigen, dass selbst wenn Sie denken, dass Ihre Frage anders ist, es nur eine Variation dieser Frage ist." Was? "obwohl Sie Ihre Version von C ++ nicht angeben" Meine Version von C ++ ist für meine Frage irrelevant. "Daher kann das gesamte Programm, in dem sich die Anweisung befindet, überhaupt alles", weiß ich, aber meine Frage betraf spezifisches Verhalten. "Ihr Kommentar spiegelt nicht den Inhalt der Antworten dort wider." Mein Kommentar spiegelt den Inhalt meiner Frage wider, den Sie erneut lesen sollten.
Zimt
2
Um den Titel zu beantworten; weil UB bedeutet, dass das Bahavioir undefiniert ist. Mehrere Compiler, die zu unterschiedlichen Zeiten im Laufe der Geschichte von unterschiedlichen Personen für unterschiedliche Architekturen erstellt wurden, mussten, wenn sie aufgefordert wurden, außerhalb der Linien zu färben und eine Implementierung in der realen Welt durchzuführen, etwas in diesen Teil außerhalb der Spezifikation einfügen, sodass die Benutzer genau das und jedes taten von ihnen verwendeten verschiedene Buntstifte. Verlassen Sie sich daher nicht auf UB
Toby

Antworten:

199

Der Compiler nimmt Ihren Code, teilt ihn in sehr einfache Anweisungen auf und kombiniert und ordnet sie dann so an, dass er sie für optimal hält.

Der Code

int i = 1;
int x = ++i + ++i;

besteht aus folgenden Anweisungen:

1. store 1 in i
2. read i as tmp1
3. add 1 to tmp1
4. store tmp1 in i
5. read i as tmp2
6. read i as tmp3
7. add 1 to tmp3
8. store tmp3 in i
9. read i as tmp4
10. add tmp2 and tmp4, as tmp5
11. store tmp5 in x

Aber obwohl dies eine nummerierte Liste ist, wie ich sie geschrieben habe, gibt es hier nur wenige Ordnungsabhängigkeiten : 1-> 2-> 3-> 4-> 5-> 10-> 11 und 1-> 6-> 7- > 8-> 9-> 10-> 11 müssen in ihrer relativen Reihenfolge bleiben. Ansonsten kann der Compiler frei neu ordnen und möglicherweise Redundanz beseitigen.

Sie können die Liste beispielsweise folgendermaßen bestellen:

1. store 1 in i
2. read i as tmp1
6. read i as tmp3
3. add 1 to tmp1
7. add 1 to tmp3
4. store tmp1 in i
8. store tmp3 in i
5. read i as tmp2
9. read i as tmp4
10. add tmp2 and tmp4, as tmp5
11. store tmp5 in x

Warum kann der Compiler das tun? Weil es keine Sequenzierung zu den Nebenwirkungen des Inkrements gibt. Aber jetzt kann der Compiler vereinfachen: Zum Beispiel gibt es in 4 einen toten Speicher: Der Wert wird sofort überschrieben. Auch tmp2 und tmp4 sind wirklich dasselbe.

1. store 1 in i
2. read i as tmp1
6. read i as tmp3
3. add 1 to tmp1
7. add 1 to tmp3
8. store tmp3 in i
5. read i as tmp2
10. add tmp2 and tmp2, as tmp5
11. store tmp5 in x

Und jetzt ist alles, was mit tmp1 zu tun hat, toter Code: Er wird nie verwendet. Und das erneute Lesen von i kann auch beseitigt werden:

1. store 1 in i
6. read i as tmp3
7. add 1 to tmp3
8. store tmp3 in i
10. add tmp3 and tmp3, as tmp5
11. store tmp5 in x

Schauen Sie, dieser Code ist viel kürzer. Der Optimierer freut sich. Der Programmierer ist nicht, weil ich nur einmal erhöht wurde. Hoppla.

Schauen wir uns stattdessen etwas anderes an, das der Compiler tun kann: Kehren wir zur Originalversion zurück.

1. store 1 in i
2. read i as tmp1
3. add 1 to tmp1
4. store tmp1 in i
5. read i as tmp2
6. read i as tmp3
7. add 1 to tmp3
8. store tmp3 in i
9. read i as tmp4
10. add tmp2 and tmp4, as tmp5
11. store tmp5 in x

Der Compiler könnte es folgendermaßen neu anordnen:

1. store 1 in i
2. read i as tmp1
3. add 1 to tmp1
4. store tmp1 in i
6. read i as tmp3
7. add 1 to tmp3
8. store tmp3 in i
5. read i as tmp2
9. read i as tmp4
10. add tmp2 and tmp4, as tmp5
11. store tmp5 in x

und dann wieder bemerken, dass ich zweimal gelesen werde, also eliminiere einen von ihnen:

1. store 1 in i
2. read i as tmp1
3. add 1 to tmp1
4. store tmp1 in i
6. read i as tmp3
7. add 1 to tmp3
8. store tmp3 in i
5. read i as tmp2
10. add tmp2 and tmp2, as tmp5
11. store tmp5 in x

Das ist schön, aber es kann noch weiter gehen: Es kann tmp1 wiederverwenden:

1. store 1 in i
2. read i as tmp1
3. add 1 to tmp1
4. store tmp1 in i
6. read i as tmp1
7. add 1 to tmp1
8. store tmp1 in i
5. read i as tmp2
10. add tmp2 and tmp2, as tmp5
11. store tmp5 in x

Dann kann das erneute Lesen von i in 6 beseitigt werden:

1. store 1 in i
2. read i as tmp1
3. add 1 to tmp1
4. store tmp1 in i
7. add 1 to tmp1
8. store tmp1 in i
5. read i as tmp2
10. add tmp2 and tmp2, as tmp5
11. store tmp5 in x

Jetzt ist 4 ein toter Laden:

1. store 1 in i
2. read i as tmp1
3. add 1 to tmp1
7. add 1 to tmp1
8. store tmp1 in i
5. read i as tmp2
10. add tmp2 and tmp2, as tmp5
11. store tmp5 in x

und jetzt können 3 und 7 zu einer Anweisung zusammengeführt werden:

1. store 1 in i
2. read i as tmp1
3+7. add 2 to tmp1
8. store tmp1 in i
5. read i as tmp2
10. add tmp2 and tmp2, as tmp5
11. store tmp5 in x

Beseitigen Sie die letzte temporäre:

1. store 1 in i
2. read i as tmp1
3+7. add 2 to tmp1
8. store tmp1 in i
10. add tmp1 and tmp1, as tmp5
11. store tmp5 in x

Und jetzt erhalten Sie das Ergebnis, das Visual C ++ Ihnen liefert.

Beachten Sie, dass in beiden Optimierungspfaden die wichtigen Ordnungsabhängigkeiten beibehalten wurden, sofern die Anweisungen nicht entfernt wurden, um nichts zu tun.

Sebastian Redl
quelle
36
Derzeit ist dies die einzige Antwort, in der die Sequenzierung erwähnt wird .
PM 2Ring
3
-1 Ich glaube nicht, dass diese Antwort klarstellt. Die beobachteten Ergebnisse hängen überhaupt nicht von Compiler-Optimierungen ab (siehe meine Antwort).
Daniel R. Collins
3
Dies setzt Lese-, Änderungs- und Schreibvorgänge voraus. Einige CPUs, wie z. B. das allgegenwärtige x86, verfügen über eine atomare Inkrementierungsoperation, die die Situation noch komplexer macht.
Mark
6
@philipxy "Der Standard hat nichts über Objektcode zu sagen." Der Standard hat auch nichts über das Verhalten dieses Snippets zu sagen - es ist UB. Das ist eine Prämisse der Frage. Das OP wollte wissen, warum Compiler in der Praxis zu unterschiedlichen und seltsamen Ergebnissen führen können. Außerdem sagt meine Antwort nicht einmal etwas über Objektcode aus.
Sebastian Redl
5
@philipxy Ich verstehe Ihren Einwand nicht. Wie bereits erwähnt, geht es um die Frage, was ein Compiler in Gegenwart von UB tun könnte, nicht um den C ++ - Standard. Warum sollte die Verwendung von Objektcode unangemessen sein, wenn untersucht wird, wie ein hypothetischer Compiler den Code transformiert? Wie wäre eigentlich alles andere als Objektcode relevant?
Konrad Rudolph
58

Während dies UB ist (wie das OP impliziert), folgen hypothetische Möglichkeiten, wie ein Compiler die 3 Ergebnisse erhalten könnte. Alle drei würden das gleiche korrekte xErgebnis liefern, wenn sie mit unterschiedlichen int i = 1, j = 1;Variablen anstelle ein und derselben verwendet würden i.

  1. beide ++ ich gebe 2 zurück, was zu x = 4 führt.
int i = 1;
int i1 = i, i2 = i;   // i1 = i2 = 1
++i1;                 // i1 = 2
++i2;                 // i2 = 2
int x = i1 + i2;      // x = 4
  1. ein ++ i gibt 2 zurück und das andere gibt 3 zurück, was zu x = 5 führt.
int i = 1;
int i1 = ++i;           // i1 = 2
int i2 = ++i;           // i2 = 3
int x = i1 + i2;        // x = 5
  1. beide ++ ich gebe 3 zurück, was zu x = 6 führt.
int i = 1;
int &i1 = i, &i2 = i;
++i1;                   // i = 2
++i2;                   // i = 3
int x = i1 + i2;        // x = 6
dxiv
quelle
2
Dies ist eine bessere Antwort als ich gehofft habe, danke.
Zimt
1
Für Option 1 hat der Compiler möglicherweise eine Notiz zur Vorinkrementierung gemacht i. Zu wissen, dass es nur einmal passieren kann, gibt es nur einmal aus. Bei Option 2 wird der Code buchstäblich in Maschinencode übersetzt, wie dies bei einem College-Compiler-Klassenprojekt der Fall sein könnte. Bei Option 3 ist es wie bei Option 1, es wurden jedoch zwei Kopien des Vorinkrements erstellt. Muss einen Vektor verwendet haben, keine Menge. :-)
Zan Lynx
@dxiv sorry, mein schlechtes, ich habe Beiträge
verwechselt
22

Der zweite scheint mir am wahrscheinlichsten.

Ich werde Option 4 ++iwählen : Beide passieren gleichzeitig.

Neuere Prozessoren bewegen sich in Richtung einiger interessanter Optimierungen, und die parallele Code-Auswertung, wo dies wie hier zulässig ist, ist eine weitere Möglichkeit, wie Compiler immer schnelleren Code erstellen. Ich sehe als praktische Implementierung Compiler, die sich in Richtung Parallelität bewegen.

Ich konnte leicht einen Race-Zustand erkennen, der nicht deterministisches Verhalten oder einen Busfehler aufgrund derselben Speicherkonflikte verursachte - alles erlaubt, da der Codierer gegen den C ++ - Vertrag verstieß - daher UB.

Meine Frage ist: Was (vernünftige) Dinge könnte ein C ++ - Compiler tun, die zu einem Ergebnis von 4 oder einem Ergebnis oder 6 führen würden?

Es könnte , aber nicht darin zählen.

Verwenden ++i + ++ioder erwarten Sie keine vernünftigen Ergebnisse.

chux - Monica wieder einsetzen
quelle
Wenn ich sowohl diese Antwort als auch die von @ dxiv akzeptieren könnte, würde ich. Danke für die Antwort.
Zimt
4
@UriRaz: Abhängig von der Wahl des Compilers bemerkt der Prozessor möglicherweise nicht einmal, dass ein Datenrisiko besteht. Beispielsweise kann der Compiler izwei Registern zuweisen , beide Register inkrementieren und beide zurückschreiben. Der Prozessor hat keine Möglichkeit, dies zu beheben. Das grundlegende Problem ist, dass weder C ++ noch moderne CPUs streng sequentiell sind. C ++ verfügt explizit über die Sequenzierung "Vorher passiert" und "Nachher passiert", um standardmäßig ein "zur gleichen Zeit" zu ermöglichen.
MSalters
1
Wir wissen jedoch, dass dies beim OP mit Visual Studio nicht der Fall ist. Die meisten gängigen ISAs, einschließlich x86 und ARM, werden als vollständig sequentielles Ausführungsmodell definiert, bei dem ein Maschinenbefehl vollständig beendet wird, bevor der nächste beginnt. Superskalar außer Betrieb muss diese Illusion für einen einzelnen Thread aufrechterhalten. (Bei anderen Threads, die den gemeinsam genutzten Speicher lesen, wird nicht garantiert, dass die Dinge in der Programmreihenfolge angezeigt werden, aber die Grundregel von OoO exec lautet, die Ausführung mit einem Thread nicht zu unterbrechen.)
Peter Cordes
1
Dies ist meine Lieblingsantwort, da es die einzige ist, die die parallele Befehlsausführung auf CPU-Ebene erwähnt. Übrigens wäre es schön, in der Antwort zu erwähnen, dass entweder aufgrund der Rennbedingungen der CPU-Thread blockiert wird und auf eine Mutex-Entsperrung am selben Speicherort wartet, was im Parallelitätsmodell sehr unoptimal ist. Zweitens - aufgrund der gleichen Racebedingung kann eine echte Antwort sein 4oder 5- abhängig vom Ausführungsmodell / der Geschwindigkeit des CPU-Threads, also ist dies UB im Herzen.
Agnius Vasiliauskas
1
@AgniusVasiliauskas Vielleicht sucht "In der Praxis, warum sollten verschiedene Compiler unterschiedliche Werte berechnen" eher nach einem leicht verständlichen, wenn man die heutigen Prozessoren vereinfacht. Die Bandbreite der Compiler- / Prozessorszenarien ist jedoch weitaus größer als die wenigen genannten Antworten. Ihre nützliche Erkenntnis ist eine weitere. IMO, Parallelität ist die Zukunft, und so konzentrierte sich diese Antwort auf diese, wenn auch auf abstrakte Weise - während sich die Zukunft noch entfaltet. IAC, der Beitrag ist populär geworden und leicht verständliche Antworten werden am besten belohnt.
chux - Monica
17

Ich denke, dass eine einfache und unkomplizierte Interpretation (ohne ein Angebot für Compiler-Optimierungen oder Multithreading) nur wäre:

  1. Zuwachs i
  2. Zuwachs i
  3. i+ Hinzufügeni

Bei izweimaliger Inkrementierung beträgt der Wert 3, und wenn er addiert wird, beträgt die Summe 6.

Betrachten Sie dies zur Überprüfung als C ++ - Funktion:

int dblInc ()
{
    int i = 1;
    int x = ++i + ++i;
    return x;   
}

Hier ist der Assembler-Code, den ich beim Kompilieren dieser Funktion mit einer alten Version des GNU C ++ - Compilers (win32, gcc Version 3.4.2 (mingw-special)) erhalte. Hier finden keine ausgefallenen Optimierungen oder Multithreading statt:

__Z6dblIncv:
    push    ebp
    mov ebp, esp
    sub esp, 8
    mov DWORD PTR [ebp-4], 1
    lea eax, [ebp-4]
    inc DWORD PTR [eax]
    lea eax, [ebp-4]
    inc DWORD PTR [eax]
    mov eax, DWORD PTR [ebp-4]
    add eax, DWORD PTR [ebp-4]
    mov DWORD PTR [ebp-8], eax
    mov eax, DWORD PTR [ebp-8]
    leave
    ret

Beachten Sie, dass sich die lokale Variable inur an einer einzigen Stelle auf dem Stapel befindet: Adresse [ebp-4]. Dieser Speicherort wird zweimal inkrementiert (in der 5.-8. Zeile der Assembly-Funktion; einschließlich scheinbar redundanter Lasten dieser Adresse in eax). In der 9. bis 10. Zeile wird dieser Wert dann geladen eaxund dann hinzugefügt eax(dh der Strom wird berechnet i + i). Dann wird es redundant auf den Stapel und zurück auf eaxden Rückgabewert kopiert (der offensichtlich 6 sein wird).

Es kann von Interesse sein, sich den C ++ - Standard (hier einen alten: ISO / IEC 14882: 1998 (E)) anzusehen, der für Ausdrücke Abschnitt 5.4 besagt:

Sofern nicht anders angegeben, ist die Reihenfolge der Auswertung von Operanden einzelner Operatoren und Unterausdrücke einzelner Ausdrücke sowie die Reihenfolge, in der Nebenwirkungen auftreten, nicht angegeben.

Mit der Fußnote:

Die Priorität von Operatoren wird nicht direkt angegeben, kann jedoch aus der Syntax abgeleitet werden.

An dieser Stelle werden zwei Beispiele für nicht angegebenes Verhalten angegeben, an denen beide den Inkrementoperator beteiligt sind (eines davon ist :) i = ++i + 1.

Wenn man möchte, kann man: eine Ganzzahl-Wrapper-Klasse erstellen (wie eine Java-Ganzzahl); Überladungsfunktionen operator+und operator++solche, dass sie die Zwischenwertobjekte zurückgeben; und damit schreiben ++iObj + ++iObjund veranlassen, dass ein Objekt mit 5 zurückgegeben wird. (Der Kürze halber habe ich hier keinen vollständigen Code eingefügt.)

Persönlich wäre ich fasziniert, wenn es ein Beispiel für einen bekannten Compiler gäbe, der die Arbeit anders als in der oben gezeigten Reihenfolge erledigt hat. Es scheint mir, dass die einfachste Implementierung darin besteht, nur zwei Assembler-Codes incfür den primitiven Typ auszuführen, bevor die Additionsoperation ausgeführt wird.

Daniel R. Collins
quelle
2
Der Inkrement-Operator hat wirklich einen sehr gut definierten "Rückgabewert"
edc65
@philipxy: Ich habe die Antwort bearbeitet, um die Passagen herauszunehmen, gegen die Sie Einwände erhoben haben. Sie können der Antwort an dieser Stelle möglicherweise mehr zustimmen oder nicht.
Daniel R. Collins
2
Dies sind keine "zwei Beispiele für nicht spezifiziertes Verhalten", sondern zwei Beispiele für undefiniertes Verhalten , ein ganz anderes Tier, das sich aus einer anderen Passage im Standard ergibt. Ich sehe, dass C ++ 98 im Text des Beispiels der Fußnote verwendet wurde, um "nicht spezifiziert" zu sagen, was dem normativen Text widerspricht, aber das wurde später behoben.
Cubbi
@Cubbi: Sowohl der Text als auch die Fußnote in dem hier zitierten Standard verwenden den Ausdruck "nicht spezifiziert", "nicht direkt spezifiziert", und er scheint mit dem Begriff aus den Definitionen in Abschnitt 1.3.13 übereinzustimmen.
Daniel R. Collins
1
@philipxy: Ich sehe, dass Sie den gleichen Kommentar zu vielen der Antworten hier wiederholt haben. Es scheint, als ob Ihre Hauptkritik wirklich mehr die Frage des OP selbst betrifft, deren Umfang nicht nur den abstrakten Standard betrifft.
Daniel R. Collins
7

Das Vernünftige, was ein Compiler tun kann, ist Common Subexpression Elimination. Dies ist bereits eine häufige Optimierung in Compilern: Wenn ein Unterausdruck wie (x+1)in einem größeren Ausdruck mehr als einmal vorkommt, muss er nur einmal berechnet werden. Zum Beispiel in a/(x+1) + b*(x+1)dem x+1Unterausdruck kann einmal berechnet werden.

Natürlich muss der Compiler wissen, welche Unterausdrücke auf diese Weise optimiert werden können. Zweimaliges rand()Anrufen sollte zwei Zufallszahlen ergeben. Nicht inline Funktionsaufrufe müssen daher von CSE ausgenommen sein. Wie Sie bemerken, gibt es keine Regel, die besagt, wie zwei Vorkommen von behandelt i++werden sollen, daher gibt es keinen Grund, sie von CSE auszunehmen.

Das Ergebnis kann tatsächlich sein, dass int x = ++i + ++i;optimiert ist int __cse = i++; int x = __cse << 1. (CSE, gefolgt von wiederholter Festigkeitsreduzierung)

MSalters
quelle
Der Standard hat nichts über Objektcode zu sagen. Dies ist nicht durch die Sprachdefinition gerechtfertigt oder damit verbunden.
philipxy
1
@philipxy: Der Standard hat nichts zu irgendeiner Form von undefiniertem Verhalten zu sagen. Das ist die Prämisse der Frage.
MSalters
7

In der Praxis rufen Sie undefiniertes Verhalten auf. Alles kann passieren, nicht nur Dinge , die man „vernünftig“ betrachten, und oft Dinge tun passieren , dass man nicht für angemessen halten. Alles ist per Definition "vernünftig".

Eine sehr vernünftige Kompilierung besteht darin, dass der Compiler feststellt, dass das Ausführen einer Anweisung ein undefiniertes Verhalten hervorruft. Daher kann die Anweisung nicht ausgeführt werden. Daher wird sie in eine Anweisung übersetzt, die Ihre Anwendung absichtlich zum Absturz bringt. Das ist sehr vernünftig.

Downvoter: GCC ist mit Ihnen nicht einverstanden.

gnasher729
quelle
Wenn der Standard etwas als "undefiniertes Verhalten" charakterisiert, bedeutet dies nicht mehr oder weniger, dass das Verhalten außerhalb der Zuständigkeit des Standards liegt . Da der Standard keinen Versuch unternimmt, die Angemessenheit von Dingen außerhalb seiner Zuständigkeit zu beurteilen, und keinen Versuch unternimmt, alle Möglichkeiten zu verbieten, in denen eine konforme Implementierung unangemessen nutzlos sein könnte, impliziert das Versäumnis des Standards, in einer bestimmten Situation Anforderungen zu stellen, kein Urteil, dass alle mögliche Handlungen sind ebenso "vernünftig".
Supercat
6

Es gibt keine vernünftige Sache, die ein Compiler tun könnte, um ein Ergebnis von 6 zu erhalten, aber es ist möglich und legitim. Ein Ergebnis von 4 ist völlig vernünftig, und ich würde ein Ergebnis von 5 Grenzwerten für vernünftig halten. Alle von ihnen sind vollkommen legal.

Hey warte! Ist nicht klar, was passieren muss? Die Addition benötigt die Ergebnisse der beiden Inkremente, daher müssen diese natürlich zuerst erfolgen. Und wir gehen von links nach rechts, also ... argh! Wenn es nur so einfach wäre. Leider ist das nicht der Fall. Wir gehen nicht von links nach rechts, und das ist das Problem.

Das Einlesen des Speicherorts in zwei Register (oder das Initialisieren beider Register aus demselben Literal, um den Roundtrip zum Speicher zu optimieren) ist für den Compiler eine sehr vernünftige Sache. Dies hat effektiv den Effekt, dass es im Verborgenen zwei verschiedene Variablen mit jeweils einem Wert von 2 gibt, die schließlich zu einem Ergebnis von 4 addiert werden. Dies ist "vernünftig", weil es schnell und effizient ist und mit beiden übereinstimmt der Standard und mit dem Code.

In ähnlicher Weise könnte der Speicherort einmal gelesen (oder die Variable aus dem Literal initialisiert) und einmal inkrementiert werden, und eine Schattenkopie in einem anderen Register könnte danach inkrementiert werden, was dazu führen würde, dass 2 und 3 addiert würden. Dies ist, würde ich sagen, grenzwertig vernünftig, obwohl vollkommen legal. Ich halte es für grenzwertig vernünftig, weil es nicht das eine oder andere ist. Es ist weder der "vernünftige" optimierte Weg, noch ist es der "vernünftige" genau pedantische Weg. Es ist etwas in der Mitte.

Das zweimalige Inkrementieren des Speicherorts (was zu einem Wert von 3 führt) und das anschließende Hinzufügen dieses Werts zu einem Endergebnis von 6 ist legitim, aber nicht ganz sinnvoll, da Speicherrundfahrten nicht gerade effizient sind. Obwohl auf einem Prozessor mit guter Speicherweiterleitung, kann es genauso gut "vernünftig" sein, dies zu tun, da der Speicher größtenteils unsichtbar sein sollte ...
Da der Compiler "weiß", dass es sich um denselben Speicherort handelt, kann er sich auch für eine Erhöhung entscheiden den Wert zweimal innerhalb eines Registers und dann auch zu sich selbst hinzufügen. Jeder Ansatz würde das Ergebnis von 6 ergeben.

Der Compiler darf Ihnen nach dem Wortlaut des Standards ein solches Ergebnis liefern, obwohl ich persönlich 6 so ziemlich als "fuck you" -Memo der Abteilung für abscheuliche Dinge betrachten würde, da es eine ziemlich unerwartete Sache ist (legal oder nicht, Es ist eine gute Sache, immer die geringsten Überraschungen zu erleben!). Wenn man sieht, wie undefiniertes Verhalten involviert ist, kann man leider nicht wirklich über "unerwartet" streiten, wie?

Also, was ist der Code, den Sie dort haben, für den Compiler? Fragen wir clang, was uns zeigt, wenn wir nett fragen (mit aufrufen -ast-dump -fsyntax-only):

ast.cpp:4:9: warning: multiple unsequenced modifications to 'i' [-Wunsequenced]
int x = ++i + ++i;
        ^     ~~
(some lines omitted)
`-CompoundStmt 0x2b3e628 <line:2:1, line:5:1>
  |-DeclStmt 0x2b3e4b8 <line:3:1, col:10>
  | `-VarDecl 0x2b3e430 <col:1, col:9> col:5 used i 'int' cinit
  |   `-IntegerLiteral 0x2b3e498 <col:9> 'int' 1
  `-DeclStmt 0x2b3e610 <line:4:1, col:18>
    `-VarDecl 0x2b3e4e8 <col:1, col:17> col:5 x 'int' cinit
      `-BinaryOperator 0x2b3e5f0 <col:9, col:17> 'int' '+'
        |-ImplicitCastExpr 0x2b3e5c0 <col:9, col:11> 'int' <LValueToRValue>
        | `-UnaryOperator 0x2b3e570 <col:9, col:11> 'int' lvalue prefix '++'
        |   `-DeclRefExpr 0x2b3e550 <col:11> 'int' lvalue Var 0x2b3e430 'i' 'int'
        `-ImplicitCastExpr 0x2b3e5d8 <col:15, col:17> 'int' <LValueToRValue>
          `-UnaryOperator 0x2b3e5a8 <col:15, col:17> 'int' lvalue prefix '++'
            `-DeclRefExpr 0x2b3e588 <col:17> 'int' lvalue Var 0x2b3e430 'i' 'int'

Wie Sie sehen können, wird an zwei Stellen dasselbe lvalue Var 0x2b3e430Präfix ++angewendet, und diese beiden befinden sich unter demselben Knoten in der Baumstruktur. Dies ist zufällig ein sehr spezieller Operator (+), der nichts Besonderes über Sequenzierung oder dergleichen sagt. Warum ist das wichtig? Nun, lesen Sie weiter.

Beachten Sie die Warnung: "Mehrere nicht sequenzierte Änderungen an 'i'" . Oh oh, das hört sich nicht gut an. Was heißt das? [basic.exec] informiert uns über Nebenwirkungen und Sequenzierung und (Absatz 10) darüber, dass Auswertungen von Operanden einzelner Operatoren und von Unterausdrücken einzelner Ausdrücke standardmäßig nicht sequenziert werden, sofern nicht ausdrücklich anders angegeben . Nun, verdammt, das ist der Fall bei operator+- nichts wird anders gesagt, also ...

Aber kümmern wir uns um vorher sequenzierte, unbestimmt sequenzierte oder nicht sequenzierte? Wer will das schon wissen?

Aus demselben Absatz geht auch hervor, dass sich nicht sequenzierte Auswertungen möglicherweise überschneiden und dass das Verhalten undefiniert ist, wenn sie sich auf denselben Speicherort beziehen (das ist der Fall!) Und dass einer möglicherweise nicht gleichzeitig ausgeführt wird. Hier wird es wirklich hässlich, denn das bedeutet, dass Sie nichts wissen und keine Garantie dafür haben, "vernünftig" zu sein. Das Unvernünftige ist eigentlich vollkommen zulässig und "vernünftig".

Damon
quelle
Die Verwendung von "vernünftig" sollte nur verhindern, dass jemand sagt: "Der Compiler kann ALLES tun, sogar den einzelnen Befehl 'set x to 7' ausgeben." Vielleicht hätte ich das klarstellen sollen.
Zimt
@cinnamon Vor vielen Jahren, als ich jung und unerfahren war, sagten mir Compiler-Ingenieure bei Sun, dass ihr Compiler absolut vernünftig gehandelt habe, um Code für undefiniertes Verhalten zu erstellen, das ich damals für unvernünftig hielt. Lektion gelernt.
gnasher729
Der Standard hat nichts über Objektcode zu sagen. Dies ist fragmentiert und unklar darüber, wie Ihre vorgeschlagenen Implementierungen durch die Sprachdefinition gerechtfertigt sind oder damit zusammenhängen.
philipxy
@philipxy: Der Standard definiert, was gut geformt und definiert ist und was nicht. Im Fall dieses Q definiert es das Verhalten als undefiniert. Über die Legalität hinaus besteht auch die vernünftige Annahme, dass Compiler effizienten Code generieren. Ja, Sie haben Recht, der Standard verlangt nicht, dass dies der Fall ist. Es ist dennoch eine vernünftige Annahme.
Damon
1

Es gibt eine Regel :

Zwischen dem vorherigen und dem nächsten Sequenzpunkt muss der gespeicherte Wert eines skalaren Objekts höchstens einmal durch Auswertung eines Ausdrucks geändert werden, andernfalls ist das Verhalten undefiniert.

Somit ist sogar x = 100 ein mögliches gültiges Ergebnis.

Für mich ist das logischste Ergebnis im Beispiel 6, weil wir den Wert von i zweimal erhöhen und sie ihn sich selbst hinzufügen. Es ist schwierig, vor den Berechnungswerten von beiden Seiten von "+" eine Addition durchzuführen.

Compiler-Entwickler können jedoch jede andere Logik implementieren.

Slavenskij
quelle
0

Es sieht so aus, als ob ++ i einen l-Wert zurückgibt, aber i ++ einen r-Wert zurückgibt.
Dieser Code ist also in Ordnung:

int i = 1;
++i = 10;
cout << i << endl;

Dieser ist nicht:

int i = 1;
i++ = 10;
cout << i << endl;

Die beiden obigen Anweisungen stimmen mit VisualC ++, GCC7.1.1, CLang und Embarcadero überein.
Aus diesem Grund ähnelt Ihr Code in VisualC ++ und GCC7.1.1 dem folgenden

int i = 1;
... do something there for instance: ++i; ++i; ...
int x = i + i;

Bei der Demontage wird zuerst i inkrementiert und i neu geschrieben. Wenn Sie versuchen, es hinzuzufügen, tun Sie dasselbe, erhöhen i und schreiben es neu. Dann fügt ich i zu i hinzu. Ich habe festgestellt, dass CLang und Embarcadero unterschiedlich handeln. Es stimmt also nicht mit der ersten Anweisung überein. Nach dem ersten ++ i speichert es das Ergebnis in einem r-Wert und fügt es dann dem zweiten i ++ hinzu.
Geben Sie hier die Bildbeschreibung ein

armagedescu
quelle
Das Problem mit "Looks Line a Lvalue" ist, dass Sie aus der Perspektive des C ++ - Standards sprechen, nicht eines Compilers.
MSalters
@MSalters Die Anweisung stimmt mit VisualStudio 2019, GCC7.1.1, clang und Embarcadero sowie mit dem ersten Code überein. Die Spezifikation ist also konsistent. Beim zweiten Code funktioniert dies jedoch anders. Der zweite Code stimmt mit VisualStudio 2019 und GCC7.1.1 überein, jedoch nicht mit clang und Embarcadero.
armagedescu
3
Nun, der erste Code in Ihrer Antwort ist legales C ++, daher stimmen die Implementierungen offensichtlich mit der Spezifikation überein. Im Vergleich zur Frage endet Ihr "etwas tun" mit einem Semikolon, was es zu einer vollständigen Aussage macht. Dadurch entsteht eine Sequenzierung, die vom C ++ - Standard verlangt wird, aber in der Frage nicht vorhanden ist.
MSalters
@ MSalters Ich wollte es als äquivalenten Pseudocode machen. Ich bin mir jedoch nicht sicher, wie ich es umformulieren soll
armagedescu
0

Ich persönlich hätte nie erwartet, dass ein Compiler in Ihrem Beispiel 6 ausgibt. Es gibt bereits gute und detaillierte Antworten auf Ihre Frage. Ich werde eine kurze Version versuchen.

Grundsätzlich ++ihandelt es sich in diesem Zusammenhang um einen zweistufigen Prozess:

  1. Erhöhen Sie den Wert von i
  2. Lesen Sie den Wert von i

Im Zusammenhang mit ++i + ++iden beiden Seiten kann der Zusatz in beliebiger Reihenfolge nach dem Standard bewertet werden. Dies bedeutet, dass die beiden Inkremente als unabhängig betrachtet werden. Es gibt auch keine Abhängigkeit zwischen den beiden Begriffen. Das Inkrementieren und Lesen von ikann daher verschachtelt sein. Dies gibt die mögliche Reihenfolge:

  1. Inkrement ifür den linken Operanden
  2. Inkrement ifür den richtigen Operanden
  3. Lesen Sie iden linken Operanden zurück
  4. Lesen Sie iden richtigen Operanden zurück
  5. Summiere die beiden: ergibt 6

Nun, da ich darüber nachdenke, ist 6 nach dem Standard am sinnvollsten. Für ein Ergebnis von 4 benötigen wir eine CPU, die zuerst iunabhängig liest , dann den Wert erhöht und an dieselbe Stelle zurückschreibt. im Grunde eine Rennbedingung. Für einen Wert von 5 benötigen wir einen Compiler, der Provisorien einführt.

Der Standard besagt jedoch, dass ++idie Variable vor der Rückgabe inkrementiert wird, dh bevor die aktuelle Codezeile tatsächlich ausgeführt wird. Der Summenoperator +muss i + inach dem Anwenden der Inkremente summieren . Ich würde sagen, dass C ++ an den Variablen arbeiten muss und nicht an einer Wertesemantik. Daher ist 6 für mich jetzt am sinnvollsten, da es auf der Semantik der Sprache und nicht auf dem Ausführungsmodell von CPUs beruht.

Simon
quelle
0
#include <stdio.h>


void a1(void)
{
    int i = 1;
    int x = ++i;
    printf("i=%d\n",i);
    printf("x=%d\n",x);
    x = x + ++i;    // Here
    printf("i=%d\n",i);
    printf("x=%d\n",x);
}


void b2(void)
{
    int i = 1;
    int x = ++i;
    printf("i=%d\n",i);
    printf("x=%d\n",x);
    x = i + ++i;    // Here
    printf("i=%d\n",i);
    printf("x=%d\n",x);
}


void main(void)
{
    a1();
    // b2();
}
John Linq
quelle
Willkommen bei stackoverflow! Können Sie in Ihrer Antwort Einschränkungen, Annahmen oder Vereinfachungen angeben? Weitere Informationen zur Beantwortung finden Sie unter diesem Link: stackoverflow.com/help/how-to-answer
Usama Abdulrehman
0

Nun, es hängt vom Design des Compilers ab. Daher hängt die Antwort davon ab, wie der Compiler die Anweisungen dekodiert. Die Verwendung von zwei verschiedenen Variablen ++ x und ++ y zum Erstellen einer Logik wäre die bessere Wahl. Hinweis: Die Ausgabe hängt von der Version der neuesten Sprachversion in ms Visual Studio ab, sofern diese aktualisiert wurde. Wenn sich die Regeln geändert haben, wird auch die Ausgabe geändert

Sam
quelle
0

Versuche dies

int i = 1;
int i1 = i, i2 = i;   // i1 = i2 = 1
++i1;                 // i1 = 2
++i2;                 // i2 = 2
int x = i1 + i2;      // x = 4
MAC27
quelle
-4

In der Praxis rufen Sie undefiniertes Verhalten auf. Alles kann passieren, nicht nur Dinge , die man „vernünftig“ betrachten, und oft Dinge tun passieren , dass man nicht für angemessen halten. Alles ist per Definition "vernünftig".

Eine sehr vernünftige Kompilierung besteht darin, dass der Compiler feststellt, dass das Ausführen einer Anweisung ein undefiniertes Verhalten hervorruft. Daher kann die Anweisung niemals ausgeführt werden. Daher wird sie in eine Anweisung übersetzt, die Ihre Anwendung absichtlich zum Absturz bringt. Das ist sehr vernünftig. Schließlich weiß der Compiler, dass dieser Absturz niemals passieren kann.

gnasher729
quelle
1
Ich glaube, Sie haben die Frage falsch verstanden. Die Frage bezieht sich auf allgemeines oder spezifisches Verhalten, das zu spezifischen Ergebnissen führen kann (die Ergebnisse von x = 4, 5 oder 6). Wenn Ihnen die Verwendung des Wortes "vernünftig" nicht gefällt, verweise ich Sie auf meinen obigen Kommentar, auf den Sie geantwortet haben: "Die Verwendung von" vernünftig "sollte nur verhindern, dass jemand sagt, der Compiler könne ALLES tun. Geben Sie sogar die einzelne Anweisung "setze x auf 7" aus. Wenn Sie eine bessere Formulierung für die Frage haben, die die allgemeine Idee beibehält, bin ich offen dafür. Außerdem haben Sie Ihre Antwort anscheinend erneut veröffentlicht.
Zimt
2
Schlagen Sie vor, eine Ihrer beiden Antworten zu löschen, da beide sehr ähnlich sind
MM