c++ - exchange - stackoverflow website




Warum ist f(i=-1, i=-1) undefiniertes Verhalten? (8)

Da die Operationen nicht sequentiell sind, gibt es nichts zu sagen, dass die Anweisungen, die die Zuweisung durchführen, nicht verschachtelt werden können. Je nach CPU-Architektur ist dies möglicherweise optimal. Die referenzierte Seite besagt Folgendes:

Wenn A nicht vor B sequenziert wird und B vor A nicht sequenziert wird, gibt es zwei Möglichkeiten:

  • Auswertungen von A und B sind nicht sequentiell: sie können in beliebiger Reihenfolge ausgeführt werden und können sich überlappen (innerhalb eines einzigen Ausführungsstrangs kann der Compiler die CPU-Anweisungen verschachteln, die A und B umfassen).

  • Auswertungen von A und B sind unbestimmt sequenziert: Sie können in beliebiger Reihenfolge ausgeführt werden, dürfen sich aber nicht überschneiden: Entweder ist A vor B vollständig, oder B ist vor A abgeschlossen. Die Reihenfolge kann beim nächsten Mal umgekehrt sein wird ausgewertet.

Das scheint an sich kein Problem zu sein - angenommen, dass die durchgeführte Operation den Wert -1 an einem Speicherort speichert. Aber es gibt auch nichts zu sagen, dass der Compiler das nicht in eine separate Menge von Anweisungen optimieren kann, die den gleichen Effekt haben, aber die fehlschlagen könnten, wenn die Operation mit einer anderen Operation am selben Speicherort verschachtelt wäre.

Stellen Sie sich zum Beispiel vor, dass es effizienter ist, den Speicher auf Null zu stellen und dann zu dekrementieren, verglichen mit dem Laden des Wertes -1.

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

könnte werden:

clear i
clear i
decr i
decr i

Jetzt bin ich -2.

Es ist wahrscheinlich ein falsches Beispiel, aber es ist möglich.

Ich las über die Reihenfolge der Bewertungsverstöße , und sie geben ein Beispiel, das mich verwirrt.

1) Wenn ein Nebeneffekt bei einem Skalarobjekt im Vergleich zu einem anderen Nebeneffekt desselben Skalarobjekts nicht sequenziert ist, ist das Verhalten nicht definiert.

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

In diesem Zusammenhang ist i ein Skalarobjekt , das scheinbar bedeutet

Arithmetische Typen (3.9.1), Aufzählungstypen, Zeigertypen, Zeiger auf Elementtypen (3.9.2), std :: nullptr_t und cv-qualifizierte Versionen dieser Typen (3.9.3) werden zusammen als Skalartypen bezeichnet.

Ich sehe nicht, wie die Aussage in diesem Fall mehrdeutig ist. Es scheint mir, dass unabhängig davon, ob das erste oder zweite Argument zuerst ausgewertet wird, i als -1 endet und beide Argumente auch -1 .

Kann jemand bitte klarstellen?

AKTUALISIEREN

Ich schätze wirklich die ganze Diskussion. Bis jetzt gefällt mir die Antwort von @ harmic sehr, da sie die Tücken und Feinheiten der Definition dieser Aussage aufdeckt, obwohl sie auf den ersten Blick einfach aussieht. @acheong87 weist auf einige Probleme hin, die bei der Verwendung von Referenzen auftauchen, aber ich denke, das ist orthogonal zum Aspekt der Nebenwirkungen in dieser Frage.

ZUSAMMENFASSUNG

Da diese Frage eine Menge Aufmerksamkeit auf sich zog, werde ich die wichtigsten Punkte / Antworten zusammenfassen. Zunächst erlaube ich mir einen kleinen Exkurs, um darauf hinzuweisen, dass "warum" eng verwandte, aber subtil unterschiedliche Bedeutungen haben kann, nämlich "aus welchem Grund ", "aus welchem Grund " und "zu welchem Zweck ". Ich werde die Antworten anhand dieser Bedeutungen von "Warum" gruppieren.

für welche Ursache

Die Hauptantwort stammt von Paul Draper , wobei Martin J eine ähnliche, aber nicht so umfassende Antwort liefert. Paul Drapers Antwort läuft darauf hinaus

Es ist undefiniertes Verhalten, weil es nicht definiert ist, was das Verhalten ist.

Die Antwort ist insgesamt sehr gut, was die Erklärung des C ++ - Standards betrifft. Es behandelt auch einige verwandte Fälle von UB wie f(++i, ++i); und f(i=1, i=-1); . In dem ersten der verwandten Fälle ist nicht klar, ob das erste Argument i+1 und das zweite i+2 oder umgekehrt sein sollte; In der zweiten ist es nicht klar, ob i nach dem Funktionsaufruf 1 oder -1 sein sollte. Beide Fälle sind UB, weil sie unter die folgende Regel fallen:

Wenn ein Nebeneffekt auf ein Skalarobjekt im Vergleich zu einem anderen Nebeneffekt desselben Skalarobjekts nicht sequenziert ist, ist das Verhalten nicht definiert.

Daher ist f(i=-1, i=-1) auch UB, da es unter die gleiche Regel fällt, obwohl die Absicht des Programmierers (IMHO) offensichtlich und eindeutig ist.

Paul Draper macht auch in seiner Schlussfolgerung ausdrücklich, dass

Konnte es ein definiertes Verhalten sein? Ja. War es definiert? Nein.

was bringt uns zu der Frage "aus welchem ​​Grund / Zweck wurde f(i=-1, i=-1) als undefiniertes Verhalten belassen?"

aus welchem ​​Grund / Zweck

Obwohl es im C ++ - Standard einige Versäumnisse gibt (vielleicht unvorsichtig), sind viele Lücken gut durchdacht und dienen einem bestimmten Zweck. Obwohl mir bewusst ist, dass der Zweck oft entweder "den Job des Compiler-Schreibers leichter machen" oder "schnellerer Code", war ich hauptsächlich daran interessiert zu wissen, ob es einen guten Grund gibt, leave f(i=-1, i=-1) als UB.

harmic und supercat liefern die wichtigsten Antworten, die einen Grund für die UB liefern. Harmic weist darauf hin, dass ein optimierender Compiler die scheinbar atomaren Zuweisungsoperationen in mehrere Maschinenbefehle zerlegen könnte, und dass er diese Anweisungen für optimale Geschwindigkeit weiter verschachteln könnte. Dies könnte zu einigen sehr überraschenden Ergebnissen führen: i lande als -2 in seinem Szenario! Daher demonstriert harmic, dass die mehrmalige Zuweisung desselben Werts zu einer Variablen negative Auswirkungen haben kann, wenn die Operationen nicht sequenziell sind.

Supercat liefert eine verwandte Darstellung der Fallstricke, in denen versucht wird, f(i=-1, i=-1) zu bringen, das zu tun, was es zu tun scheint. Er weist darauf hin, dass es auf einigen Architekturen harte Einschränkungen gegen mehrere gleichzeitige Schreibvorgänge auf die gleiche Speicheradresse gibt. Ein Compiler könnte es schwer haben, dies zu erfassen, wenn es sich um etwas weniger Triviales als f(i=-1, i=-1) .

davidf bietet auch ein Beispiel für Interleaving-Anweisungen, die sehr ähnlich denen von Harmic sind.

Obwohl alle Beispiele von harmic, supercat und davidf ein wenig erfunden sind, dienen sie dennoch dazu, einen greifbaren Grund dafür zu liefern, warum f(i=-1, i=-1) ein undefiniertes Verhalten sein sollte.

Ich akzeptierte die Antwort von harmic, weil es die beste Arbeit leistete, alle Bedeutungen des Warum anzugehen, obwohl Paul Drapers Antwort den Teil "aus welchem ​​Grund" besser ansprach.

Andere Antwort

JohnB weist darauf hin, dass wir, wenn wir überladene Zuweisungsoperatoren betrachten (statt nur einfache Skalare), auch Probleme bekommen können.


Der Zuweisungsoperator könnte überladen sein. In diesem Fall könnte die Reihenfolge von Bedeutung sein:

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

Die Verwirrung besteht darin, dass das Speichern eines konstanten Werts in einer lokalen Variablen nicht ein atomarer Befehl in jeder Architektur ist, auf der das C ausgeführt werden soll. Der Prozessor, auf dem der Code läuft, ist in diesem Fall wichtiger als der Compiler. Zum Beispiel benötigt bei ARM, bei dem jeder Befehl keine vollständige 32-Bit-Konstante tragen kann, das Speichern eines int in einer Variablen mehr als einen Befehl. Beispiel mit diesem Pseudo-Code, wo Sie nur 8 Bits gleichzeitig speichern können und in einem 32-Bit-Register arbeiten müssen, ich bin ein int32:

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

Sie können sich vorstellen, dass der Compiler, wenn er ihn optimieren möchte, dieselbe Sequenz zweimal verschachteln kann und Sie nicht wissen, welcher Wert in i geschrieben wird; und lassen Sie uns sagen, dass er nicht sehr klug ist:

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

In meinen Tests ist gcc jedoch so freundlich zu erkennen, dass derselbe Wert zweimal verwendet wird und einmal generiert wird und nichts Seltsames tut. Ich bekomme -1, -1 Aber mein Beispiel ist immer noch gültig, da es wichtig ist zu berücksichtigen, dass selbst eine Konstante nicht so offensichtlich ist, wie es scheint.


Dies ist nur die Antwort auf das "Ich bin mir nicht sicher, was" Skalarobjekt "könnte neben etwas wie ein int oder ein float".

Ich würde das "skalare Objekt" als eine Abkürzung von "Skalartyp-Objekt" oder nur "Skalartypvariable" interpretieren. Dann sind pointer , enum (Konstante) vom Skalartyp.

Dies ist ein MSDN-Artikel der Skalartypen .


Es sieht für mich so aus, als wäre die einzige Regel für die Sequenzierung von Funktionsargumentausdrücken hier:

3) Beim Aufrufen einer Funktion (unabhängig davon, ob die Funktion inline ist oder nicht und ob explizite Funktionsaufrufsyntax verwendet wird oder nicht), sind alle Werteberechnungen und Nebeneffekte, die jedem Argumentausdruck oder dem Postfixausdruck, der die aufgerufene Funktion bezeichnet, zugeordnet vor der Ausführung jedes Ausdrucks oder jeder Anweisung im Rumpf der aufgerufenen Funktion sequenziert.

Dies definiert nicht die Reihenfolge zwischen Argumentausdrücken, so dass wir in diesem Fall enden:

1) Wenn ein Nebeneffekt auf ein Skalarobjekt im Vergleich zu einem anderen Nebeneffekt desselben Skalarobjekts nicht sequenziert ist, ist das Verhalten nicht definiert.

In der Praxis läuft bei den meisten Compilern das von Ihnen angegebene Beispiel gut (im Gegensatz zum "Löschen Ihrer Festplatte" und anderen theoretischen undefinierten Verhaltenskonsequenzen).
Es ist jedoch eine Haftung, da es von einem bestimmten Compiler-Verhalten abhängt, auch wenn die beiden zugewiesenen Werte gleich sind. Wenn Sie verschiedene Werte zuweisen möchten, wären die Ergebnisse natürlich "wirklich" undefiniert:

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

Tatsächlich gibt es einen Grund, sich nicht auf die Tatsache zu verlassen, dass der Compiler prüft, ob i zweimal mit dem gleichen Wert zugewiesen wurde, so dass es möglich ist, ihn durch eine einzelne Zuweisung zu ersetzen. Was ist, wenn wir ein paar Ausdrücke haben?

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)));
}

Zuerst bedeutet "skalares Objekt" einen Typ wie ein int , float oder ein Zeiger (siehe Was ist ein skalares Objekt in C ++? ).

Zweitens mag es so offensichtlich erscheinen

f(++i, ++i);

hätte ein undefiniertes Verhalten. Aber

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

ist weniger offensichtlich.

Ein etwas anderes Beispiel:

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

Welche Zuordnung passierte "zuletzt", i = 1 oder i = -1 ? Es ist nicht im Standard definiert. Wirklich, das bedeutet, dass i 5 (siehe die Antwort von harmic für eine völlig plausible Erklärung dafür, wie das sein könnte). Oder Sie programmieren könnte segfault. Oder formatieren Sie Ihre Festplatte neu.

Aber jetzt fragst du: "Was ist mit meinem Beispiel? Ich habe für beide Aufgaben den gleichen Wert ( -1 ) verwendet. Was könnte daran unklar sein?"

Sie haben Recht ... außer in der Art und Weise, wie das C ++ - Normenkomitee dies beschrieben hat.

Wenn ein Nebeneffekt auf ein Skalarobjekt im Vergleich zu einem anderen Nebeneffekt desselben Skalarobjekts nicht sequenziert ist, ist das Verhalten nicht definiert.

Sie hätten eine spezielle Ausnahme für Ihren speziellen Fall machen können, aber sie taten es nicht. (Und warum sollten sie? Welchen Nutzen hätte das jemals?) Also könnte i immer noch 5 . Oder Ihre Festplatte könnte leer sein. So ist die Antwort auf Ihre Frage:

Es ist undefiniertes Verhalten, weil es nicht definiert ist, was das Verhalten ist.

(Dies verdient Beachtung, da viele Programmierer denken, "undefiniert" bedeutet "zufällig" oder "unvorhersehbar". Es tut dies nicht; es bedeutet, dass es nicht durch den Standard definiert ist. Das Verhalten könnte 100% konsistent sein und immer noch undefiniert sein.)

Konnte es ein definiertes Verhalten sein? Ja. War es definiert? Nein. Daher ist es "undefiniert".

Das heißt, "undefined" bedeutet nicht, dass ein Compiler Ihre Festplatte formatiert ... es bedeutet, dass es könnte und es wäre immer noch ein standardkonformer Compiler. Realistisch gesehen bin ich mir sicher, dass g ++, Clang und MSVC alles tun werden, was Sie erwartet haben. Sie würden einfach nicht "müssen".

Eine andere Frage könnte sein: Warum hat das C ++ - Standardkomitee beschlossen, diesen Nebeneffekt unse- quenziert zu machen? . Diese Antwort wird die Geschichte und die Meinungen des Ausschusses einbeziehen. Oder: Was ist gut daran, dass dieser Nebeneffekt in C ++ nicht sequenziert ist? , die jede Begründung zulässt, unabhängig davon, ob es sich um die eigentliche Begründung des Normenausschusses handelte oder nicht. Sie können diese Fragen hier oder unter programmers.stackexchange.com stellen.


C ++ 17 definiert strengere Bewertungsregeln. Insbesondere werden Funktionsargumente (in nicht spezifizierter Reihenfolge) abgearbeitet.

N5659 §4.6:15
Die Bewertungen A und B sind unbestimmt sequenziert, wenn entweder A sequenziert wird, bevor B oder B vor A sequenziert werden, aber es ist nicht spezifiziert, welches. [ Hinweis : Indeterminierte sequenzielle Auswertungen können nicht überlappen, aber beide könnten zuerst ausgeführt werden. - Endnote ]

N5659 § 8.2.2:5
Die Initialisierung eines Parameters, einschließlich jeder zugehörigen Wertberechnung und Nebenwirkung, ist in Bezug auf jeden anderen Parameter unbestimmt sequenziert.

Es erlaubt einige Fälle, die vorher UB waren:

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




undefined-behavior