An welchem ​​Punkt in der Schleife wird ein Ganzzahlüberlauf zu einem undefinierten Verhalten?

86

Dies ist ein Beispiel zur Veranschaulichung meiner Frage, die einen viel komplizierteren Code enthält, den ich hier nicht posten kann.

#include <stdio.h>
int main()
{
    int a = 0;
    for (int i = 0; i < 3; i++)
    {
        printf("Hello\n");
        a = a + 1000000000;
    }
}

Dieses Programm enthält undefiniertes Verhalten auf meiner Plattform, da aes in der 3. Schleife überläuft.

Hat das gesamte Programm ein undefiniertes Verhalten oder erst, nachdem der Überlauf tatsächlich stattgefunden hat ? Könnte der Compiler arbeiten möglicherweise , dass out a wird überlaufen , so dass es die ganze Schleife undefiniert erklären kann und sich nicht die Mühe , die printfs obwohl sie alle vor dem Überlauf passieren laufen?

(C und C ++ mit Tags versehen, obwohl sie unterschiedlich sind, da ich an Antworten für beide Sprachen interessiert wäre, wenn sie unterschiedlich sind.)

jcoder
quelle
7
aa
Ich frage
12
Vielleicht gefällt Ihnen My Little Optimizer: Undefiniertes Verhalten ist Magie von CppCon in diesem Jahr. Es geht darum, welche Optimierungen Compiler basierend auf undefiniertem Verhalten ausführen können.
TartanLlama

Antworten:

108

Wenn Sie an einer rein theoretischen Antwort interessiert sind, erlaubt der C ++ - Standard undefiniertes Verhalten für "Zeitreisen":

[intro.execution]/5: Eine konforme Implementierung, die ein wohlgeformtes Programm ausführt, muss dasselbe beobachtbare Verhalten erzeugen wie eine der möglichen Ausführungen der entsprechenden Instanz der abstrakten Maschine mit demselben Programm und derselben Eingabe. Jedoch , wenn eine solche Ausführung eine undefinierte Operation enthält, diese Internationale Norm legt keine Anforderung an die Umsetzung dieses Programms mit dem Eingang der Ausführung (auch nicht im Hinblick auf Operationen vor dem ersten undefinierten Betrieb)

Wenn Ihr Programm undefiniertes Verhalten enthält, ist das Verhalten Ihres gesamten Programms undefiniert.

TartanLlama
quelle
4
@KeithThompson: Aber dann ist die sneeze()Funktion selbst für nichts in der Klasse Demon(von der die Nasensorte eine Unterklasse ist) undefiniert , was das Ganze sowieso kreisförmig macht.
Sebastian Lenartowicz
1
Da printf jedoch möglicherweise nicht zurückkehrt, werden die ersten beiden Runden definiert, da bis dahin keine UB mehr vorhanden sein wird. Siehe stackoverflow.com/questions/23153445/…
usr
1
Aus diesem Grund hat ein Compiler technisch gesehen das Recht, "nop" für den Linux-Kernel auszugeben
Crashworks
3
@Crashworks Und deshalb wird Linux in nicht portierbares C geschrieben und als solches kompiliert (dh eine Obermenge von C, die einen bestimmten Compiler mit bestimmten Optionen erfordert, wie z. B. -fno-strict-aliasing)
user253751,
3
@usr Ich gehe davon aus, dass es definiert ist, wenn printfnicht zurückgegeben wird, aber wenn zurückgegeben printfwird, kann das undefinierte Verhalten Probleme verursachen, bevor printfes aufgerufen wird. Daher Zeitreise. printf("Hello\n");und dann kompiliert die nächste Zeile alsundoPrintf(); launchNuclearMissiles();
user253751
31

Lassen Sie mich zunächst den Titel dieser Frage korrigieren:

Undefiniertes Verhalten gehört nicht (speziell) zum Bereich der Ausführung.

Undefiniertes Verhalten wirkt sich auf alle Schritte aus: Kompilieren, Verknüpfen, Laden und Ausführen.

Einige Beispiele, um dies zu zementieren, bedenken, dass kein Abschnitt erschöpfend ist:

  • Der Compiler kann davon ausgehen, dass Teile des Codes, die undefiniertes Verhalten enthalten, niemals ausgeführt werden, und daher annehmen, dass die Ausführungspfade, die zu ihnen führen würden, toter Code sind. Sehen Sie, was jeder C-Programmierer über undefiniertes Verhalten von niemand anderem als Chris Lattner wissen sollte .
  • Der Linker kann davon ausgehen, dass bei mehreren Definitionen eines schwachen Symbols (erkennbar am Namen) alle Definitionen dank der One Definition Rule identisch sind
  • Der Loader (falls Sie dynamische Bibliotheken verwenden) kann dasselbe annehmen und so das erste gefundene Symbol auswählen. Dies wird normalerweise (ab) zum Abfangen von Anrufen mit LD_PRELOADTricks unter Unixen verwendet
  • Die Ausführung kann fehlschlagen (SIGSEV), wenn Sie baumelnde Zeiger verwenden

Dies ist das Unheimliche an undefiniertem Verhalten: Es ist nahezu unmöglich, im Voraus vorherzusagen, welches genaue Verhalten eintreten wird, und diese Vorhersage muss bei jedem Update der Toolchain, des zugrunde liegenden Betriebssystems, ... überprüft werden.


Ich empfehle dieses Video von Michael Spencer (LLVM-Entwickler): CppCon 2016: Mein kleiner Optimierer: Undefiniertes Verhalten ist Magie .

Matthieu M.
quelle
3
Das macht mir Sorgen. In meinem realen Code ist es komplex, aber ich habe möglicherweise einen Fall, in dem es immer überläuft. Und das interessiert mich nicht wirklich, aber ich mache mir Sorgen, dass "korrekter" Code auch davon betroffen sein wird. Natürlich muss ich es reparieren, aber das
Reparieren
8
@jcoder: Hier gibt es eine wichtige Flucht. Der Compiler darf keine Eingabedaten erraten. Solange es mindestens eine Eingabe gibt, für die kein undefiniertes Verhalten auftritt, muss der Compiler sicherstellen, dass diese bestimmte Eingabe immer noch die richtige Ausgabe erzeugt. Das gruselige Gerede über gefährliche Optimierungen gilt nur für unvermeidbare UB. In der Praxis erzeugt argcder Fall , wenn Sie ihn als Schleifenzahl verwendet hätten, argc=1kein UB, und der Compiler wäre gezwungen, damit umzugehen.
MSalters
@jcoder: In diesem Fall ist dies kein toter Code. Der Compiler könnte jedoch klug genug sein, um daraus zu schließen, dass er inicht mehr als Nmal inkrementiert werden kann und daher sein Wert begrenzt ist.
Matthieu M.
4
@jcoder: Wenn f(good);etwas X tut und f(bad);undefiniertes Verhalten aufruft, dann f(good);wird garantiert, dass ein Programm, das nur aufruft , X f(good); f(bad);
4
@ Hurkyl interessanter, wenn Ihr Code ist if(foo) f(good); else f(bad);, wird ein intelligenter Compiler den Vergleich und die Produktion und eine bedingungslose wegwerfen foo(good).
John Dvorak
28

Ein aggressiv optimierter C- oder C ++ - Compiler, der auf 16 Bit intabzielt, weiß, dass das Verhalten beim Hinzufügen 1000000000zu einem intTyp undefiniert ist .

Es wird entweder durch Standard erlaubt , alles zu tun es will , das könnte das Löschen des gesamten Programms, verlassen int main(){}.

Aber was ist mit größeren ints? Ich kenne noch keinen Compiler, der dies tut (und ich bin keineswegs ein Experte für C- und C ++ - Compilerdesign), aber ich stelle mir vor, dass ein Compiler, der auf 32 Bit oder höher abzielt, irgendwannint herausfinden wird, dass die Schleife ist unendlich ( iändert sich nicht) und wird daher airgendwann überlaufen. So kann es wieder die Ausgabe auf optimieren int main(){}. Der Punkt, den ich hier ansprechen möchte, ist, dass sich mit zunehmender Aggressivität der Compiler-Optimierungen immer mehr undefinierte Verhaltenskonstrukte auf unerwartete Weise manifestieren.

Die Tatsache, dass Ihre Schleife unendlich ist, ist an sich nicht undefiniert, da Sie auf die Standardausgabe im Schleifenkörper schreiben.

Bathseba
quelle
3
Ist es nach dem Standard erlaubt, alles zu tun, was er will, noch bevor sich das undefinierte Verhalten manifestiert? Wo steht das?
Jimifiki
4
warum 16 Bit? Ich denke, OP sucht nach einem 32-Bit-Überlauf mit Vorzeichen.
4386427
8
@jimifiki Im Standard. C ++ 14 (N4140) 1.3.24 "Udefiniertes Verhalten = Verhalten, für das diese Internationale Norm keine Anforderungen stellt." Plus eine lange Notiz, die ausarbeitet. Der Punkt ist jedoch, dass nicht das Verhalten einer "Anweisung" undefiniert ist, sondern das Verhalten des Programms. Das heißt, solange UB durch eine Regel im Standard (oder durch das Fehlen einer Regel) ausgelöst wird, gilt der Standard nicht mehr für das gesamte Programm . So kann sich jeder Teil des Programms so verhalten, wie er will.
Angew ist nicht mehr stolz auf SO
5
Die erste Aussage ist falsch. Wenn intes sich um 16-Bit handelt, erfolgt die Addition in long(da der Literaloperand einen Typ hat long), wo er genau definiert ist, und wird dann durch eine implementierungsdefinierte Konvertierung zurück in konvertiert int.
R .. GitHub STOP HELPING ICE
2
@usr das Verhalten von printfwird durch den Standard definiert, um immer zurückzukehren
MM
11

Technisch gesehen ist nach dem C ++ - Standard, wenn ein Programm undefiniertes Verhalten enthält, das Verhalten des gesamten Programms selbst zur Kompilierungszeit (bevor das Programm überhaupt ausgeführt wird) undefiniert.

In der Praxis ist zumindest das Verhalten des Programms bei der dritten Iteration der Schleife (unter der Annahme einer 32-Bit-Maschine) undefiniert, da der Compiler (als Teil einer Optimierung) davon ausgehen kann, dass der Überlauf nicht auftritt Es ist wahrscheinlich, dass Sie vor der dritten Iteration korrekte Ergebnisse erhalten. Da das Verhalten des gesamten Programms jedoch technisch undefiniert ist, hindert nichts das Programm daran, eine völlig falsche Ausgabe (einschließlich keiner Ausgabe) zu generieren, zur Laufzeit zu einem beliebigen Zeitpunkt während der Ausführung abzustürzen oder sogar nicht vollständig zu kompilieren (da sich das undefinierte Verhalten auf erstreckt Kompilierungszeit).

Undefiniertes Verhalten bietet dem Compiler mehr Raum für Optimierungen, da bestimmte Annahmen darüber, was der Code tun muss, beseitigt werden. Dabei wird nicht garantiert, dass Programme, die auf Annahmen mit undefiniertem Verhalten beruhen, wie erwartet funktionieren. Daher sollten Sie sich nicht auf ein bestimmtes Verhalten verlassen, das gemäß dem C ++ - Standard als undefiniert gilt.

bwDraco
quelle
Was ist, wenn der UB-Teil innerhalb eines if(false) {}Bereichs liegt? Vergiftet dies das gesamte Programm, da der Compiler davon ausgeht, dass alle Zweige genau definierte Teile der Logik enthalten und daher mit falschen Annahmen arbeiten?
mlvljr
1
Der Standard stellt keinerlei Anforderungen an undefiniertes Verhalten. Theoretisch vergiftet er also das gesamte Programm. In der Praxis wird jedoch jeder optimierende Compiler wahrscheinlich nur den toten Code entfernen, sodass er wahrscheinlich keine Auswirkungen auf die Ausführung hat. Sie sollten sich dennoch nicht auf dieses Verhalten verlassen.
bwDraco
Gut zu wissen, danke :)
mlvljr
9

Um zu verstehen, warum undefiniertes Verhalten "Zeitreisen" kann, wie @TartanLlama es angemessen ausdrückt , werfen wir einen Blick auf die "Als-ob" -Regel:

1.9 Programmausführung

1 Die semantischen Beschreibungen in dieser Internationalen Norm definieren eine parametrisierte nichtdeterministische abstrakte Maschine. Diese Internationale Norm stellt keine Anforderungen an die Struktur konformer Implementierungen. Insbesondere müssen sie die Struktur der abstrakten Maschine nicht kopieren oder emulieren. Vielmehr sind konforme Implementierungen erforderlich, um (nur) das beobachtbare Verhalten der abstrakten Maschine zu emulieren, wie nachstehend erläutert.

Damit könnten wir das Programm als 'Black Box' mit einer Eingabe und einer Ausgabe betrachten. Die Eingabe kann Benutzereingaben, Dateien und viele andere Dinge sein. Die Ausgabe ist das im Standard erwähnte 'beobachtbare Verhalten'.

Der Standard definiert nur eine Zuordnung zwischen Eingabe und Ausgabe, sonst nichts. Dazu wird eine 'Beispiel-Blackbox' beschrieben, es wird jedoch ausdrücklich darauf hingewiesen, dass jede andere Blackbox mit derselben Zuordnung gleichermaßen gültig ist. Dies bedeutet, dass der Inhalt der Black Box irrelevant ist.

In diesem Sinne wäre es nicht sinnvoll zu sagen, dass undefiniertes Verhalten zu einem bestimmten Zeitpunkt auftritt. In der Beispielimplementierung der Black Box könnten wir sagen, wo und wann es passiert, aber die tatsächliche Black Box könnte etwas völlig anderes sein, sodass wir nicht mehr sagen können, wo und wann es passiert. Theoretisch könnte ein Compiler beispielsweise entscheiden, alle möglichen Eingaben aufzulisten und die resultierenden Ausgaben vorab zu berechnen. Dann wäre das undefinierte Verhalten während der Kompilierung aufgetreten.

Undefiniertes Verhalten ist das Nichtvorhandensein einer Zuordnung zwischen Eingabe und Ausgabe. Ein Programm kann für einige Eingaben ein undefiniertes Verhalten haben, für andere jedoch ein definiertes Verhalten. Dann ist die Zuordnung zwischen Eingabe und Ausgabe einfach unvollständig; Es gibt Eingaben, für die keine Zuordnung zur Ausgabe vorhanden ist.
Das Programm in der Frage hat ein undefiniertes Verhalten für jede Eingabe, daher ist die Zuordnung leer.

alain
quelle
6

Angenommen, es inthandelt sich um ein 32-Bit-Verhalten. Bei der dritten Iteration tritt undefiniertes Verhalten auf. Wenn zum Beispiel die Schleife nur bedingt erreichbar wäre oder vor der dritten Iteration bedingt beendet werden könnte, gäbe es kein undefiniertes Verhalten, es sei denn, die dritte Iteration ist tatsächlich erreicht. Im Falle eines undefinierten Verhaltens ist jedoch die gesamte Ausgabe des Programms undefiniert, einschließlich der Ausgabe, die "in der Vergangenheit" in Bezug auf den Aufruf von undefiniertem Verhalten liegt. In Ihrem Fall bedeutet dies beispielsweise, dass keine Garantie dafür besteht, dass 3 "Hallo" -Nachrichten in der Ausgabe angezeigt werden.

R .. GitHub HÖREN SIE AUF, EIS ZU HELFEN
quelle
6

Die Antwort von TartanLlama ist richtig. Das undefinierte Verhalten kann jederzeit auftreten, auch während der Kompilierungszeit. Dies mag absurd erscheinen, ist jedoch eine wichtige Funktion, damit Compiler das tun können, was sie tun müssen. Es ist nicht immer einfach, ein Compiler zu sein. Sie müssen jedes Mal genau das tun, was in der Spezifikation angegeben ist. Manchmal kann es jedoch ungeheuer schwierig sein, das Auftreten eines bestimmten Verhaltens zu beweisen. Wenn Sie sich an das Problem des Anhaltens erinnern, ist es ziemlich trivial, Software zu entwickeln, für die Sie nicht nachweisen können, ob sie abgeschlossen ist oder in eine Endlosschleife eintritt, wenn eine bestimmte Eingabe eingegeben wird.

Wir könnten Compiler pessimistisch machen und ständig aus Angst kompilieren, dass die nächste Anweisung eines dieser Probleme sein könnte, aber das ist nicht vernünftig. Stattdessen geben wir dem Compiler einen Pass: Bei diesen Themen zu "undefiniertem Verhalten" sind sie von jeglicher Verantwortung befreit. Undefiniertes Verhalten besteht aus all den Verhaltensweisen, die so subtil schändlich sind, dass wir Probleme haben, sie von den wirklich fiesen, schändlichen Halteproblemen und so weiter zu trennen.

Es gibt ein Beispiel, das ich gerne poste, obwohl ich zugebe, dass ich die Quelle verloren habe, also muss ich es umschreiben. Es war von einer bestimmten Version von MySQL. In MySQL hatten sie einen Ringpuffer, der mit vom Benutzer bereitgestellten Daten gefüllt war. Sie wollten natürlich sicherstellen, dass die Daten nicht über den Puffer laufen, also hatten sie eine Überprüfung:

if (currentPtr + numberOfNewChars > endOfBufferPtr) { doOverflowLogic(); }

Es sieht vernünftig aus. Was ist jedoch, wenn numberOfNewChars wirklich groß ist und überläuft? Dann wird es umbrochen und zu einem Zeiger, der kleiner als ist endOfBufferPtr, sodass die Überlauflogik niemals aufgerufen wird. Also fügten sie vor diesem einen zweiten Scheck hinzu:

if (currentPtr + numberOfNewChars < currentPtr) { detectWrapAround(); }

Es sieht so aus, als hätten Sie sich um den Pufferüberlauffehler gekümmert, oder? Es wurde jedoch ein Fehler gemeldet, der besagte, dass dieser Puffer bei einer bestimmten Version von Debian übergelaufen ist! Eine sorgfältige Untersuchung ergab, dass diese Version von Debian die erste war, die eine besonders aktuelle Version von gcc verwendete. In dieser Version von gcc hat der Compiler erkannt, dass currentPtr + numberOfNewChars niemals ein kleinerer Zeiger als currentPtr sein kann, da ein Überlauf für Zeiger ein undefiniertes Verhalten ist! Das war ausreichend für gcc, um die gesamte Prüfung zu optimieren, und plötzlich waren Sie nicht mehr vor Pufferüberläufen geschützt , obwohl Sie den Code zur Prüfung geschrieben haben!

Dies war ein spezielles Verhalten. Alles war legal (obwohl, wie ich gehört habe, gcc diese Änderung in der nächsten Version rückgängig gemacht hat). Es ist nicht das, was ich als intuitives Verhalten betrachten würde, aber wenn Sie Ihre Vorstellungskraft ein wenig erweitern, ist es leicht zu erkennen, wie eine geringfügige Variante dieser Situation zu einem Halteproblem für den Compiler werden kann. Aus diesem Grund haben die Spezifikationsschreiber "Undefiniertes Verhalten" festgelegt und festgestellt, dass der Compiler absolut alles tun kann, was ihm gefällt.

Cort Ammon
quelle
Ich betrachte keine besonders erstaunlichen Compiler, die sich manchmal so verhalten, als ob vorzeichenbehaftete Arithmetik für Typen ausgeführt wird, deren Bereich über "int" hinausgeht, insbesondere wenn man bedenkt, dass selbst wenn eine einfache Codegenerierung auf x86 durchgeführt wird, dies manchmal effizienter ist als das Abschneiden von Zwischenprodukten Ergebnisse. Erstaunlicher ist, wenn der Überlauf andere Berechnungen beeinflusst, die in gcc auftreten können, selbst wenn Code das Produkt zweier uint16_t-Werte in einem uint32_t speichert - eine Operation, die keinen plausiblen Grund haben sollte, in einem nicht desinfizierenden Build überraschend zu handeln.
Supercat
Die richtige Prüfung wäre natürlich, if(numberOfNewChars > endOfBufferPtr - currentPtr)vorausgesetzt, numberOfNewChars kann niemals negativ sein und currentPtr zeigt immer auf eine Stelle innerhalb des Puffers, für die Sie nicht einmal die lächerliche "Wraparound" -Überprüfung benötigen. (Ich glaube nicht, dass der von Ihnen bereitgestellte Code die Hoffnung hat, in einem Umlaufpuffer zu arbeiten - Sie haben in der Paraphrase alles Notwendige ausgelassen, also ignoriere ich auch diesen Fall)
Random832
@ Random832 Ich habe eine Tonne ausgelassen. Ich habe versucht, den größeren Kontext zu zitieren, aber da ich meine Quelle verloren habe, habe ich festgestellt, dass das Paraphrasieren des Kontexts mich in größere Schwierigkeiten gebracht hat, sodass ich ihn weglasse. Ich muss diesen verdammten Fehlerbericht wirklich finden, damit ich ihn richtig zitieren kann. Es ist wirklich ein wirkungsvolles Beispiel dafür, wie Sie denken können, Sie hätten Code in eine Richtung geschrieben und ihn ganz anders kompilieren lassen.
Cort Ammon
Dies ist mein größtes Problem mit undefiniertem Verhalten. Es macht es manchmal unmöglich, korrekten Code zu schreiben, und wenn der Compiler ihn erkennt, sagt er Ihnen standardmäßig nicht, dass er undefiniertes Verhalten ausgelöst hat. In diesem Fall möchte der Benutzer einfach rechnen - Zeiger oder nicht - und all seine harte Arbeit, sicheren Code zu schreiben, wurde rückgängig gemacht. Es sollte zumindest eine Möglichkeit geben, einen Codeabschnitt mit Anmerkungen zu versehen - hier keine ausgefallenen Optimierungen. C / C ++ wird in zu vielen kritischen Bereichen verwendet, um diese gefährliche Situation zugunsten der Optimierung fortzusetzen
John McGrath
4

Über die theoretischen Antworten hinaus wäre eine praktische Beobachtung, dass Compiler seit langem verschiedene Transformationen auf Schleifen angewendet haben, um den Arbeitsaufwand in ihnen zu reduzieren. Zum Beispiel gegeben:

for (int i=0; i<n; i++)
  foo[i] = i*scale;

Ein Compiler könnte dies in Folgendes umwandeln:

int temp = 0;
for (int i=0; i<n; i++)
{
  foo[i] = temp;
  temp+=scale;
}

So wird bei jeder Schleifeniteration eine Multiplikation gespeichert. Eine zusätzliche Form der Optimierung, die Compiler mit unterschiedlichem Grad an Aggressivität anpassten, würde daraus Folgendes machen:

if (n > 0)
{
  int temp1 = n*scale;
  int *temp2 = foo;
  do
  {
    temp1 -= scale;
    *temp2++ = temp1;
  } while(temp1);
}

Selbst auf Maschinen mit stillem Umlauf bei Überlauf kann dies zu Fehlfunktionen führen, wenn eine Zahl kleiner als n vorhanden ist, die bei Multiplikation mit der Skalierung 0 ergibt. Sie kann sich auch in eine Endlosschleife verwandeln, wenn die Skalierung mehr als einmal aus dem Speicher gelesen wird Der Wert wurde unerwartet geändert (in jedem Fall, in dem "scale" die mittlere Schleife ändern könnte, ohne UB aufzurufen, darf ein Compiler die Optimierung nicht durchführen).

Während die meisten derartigen Optimierungen in Fällen, in denen zwei kurze vorzeichenlose Typen multipliziert werden, um einen Wert zwischen INT_MAX + 1 und UINT_MAX zu erhalten, keine Probleme haben würden, gibt es in einigen Fällen, in denen eine solche Multiplikation innerhalb einer Schleife dazu führen kann, dass die Schleife vorzeitig beendet wird . Ich habe solche Verhaltensweisen aufgrund von Vergleichsanweisungen im generierten Code nicht bemerkt, aber es ist in Fällen zu beobachten, in denen der Compiler den Überlauf verwendet, um zu schließen, dass eine Schleife höchstens vier oder weniger Mal ausgeführt werden kann. Es generiert standardmäßig keine Warnungen in Fällen, in denen einige Eingaben UB verursachen würden und andere nicht, selbst wenn seine Schlussfolgerungen dazu führen, dass die Obergrenze der Schleife ignoriert wird.

Superkatze
quelle
4

Undefiniertes Verhalten ist per Definition eine Grauzone. Sie können einfach nicht vorhersagen, was es tun wird oder nicht - das bedeutet "undefiniertes Verhalten" .

Seit jeher haben Programmierer immer versucht, Reste der Definiertheit aus einer undefinierten Situation zu retten. Sie haben einige Code bekam sie wirklich nutzen wollen, aber das erweist sich als nicht definiert werden, so dass sie versuchen zu argumentieren: „Ich weiß , es ist nicht definiert, aber sicher wird es, im schlimmsten Fall, tun dies oder das, es wird nie tun , dass . " Und manchmal sind diese Argumente mehr oder weniger richtig - aber oft sind sie falsch. Und wenn die Compiler immer schlauer werden (oder, wie manche sagen, immer schlauer), ändern sich die Grenzen der Frage ständig.

Wenn Sie also Code schreiben möchten, der garantiert funktioniert und der noch lange funktioniert, gibt es nur eine Möglichkeit: Vermeiden Sie das undefinierte Verhalten um jeden Preis. Wahrlich, wenn du dich damit beschäftigst, wird es zurückkommen, um dich zu verfolgen.

Steve Summit
quelle
und doch, hier ist die Sache ... Compiler können undefiniertes Verhalten verwenden, um zu optimieren, aber SIE sagen es Ihnen im Allgemeinen nicht. Wenn wir also dieses großartige Tool haben, das Sie unbedingt vermeiden müssen, warum kann der Compiler Ihnen dann keine Warnung geben, damit Sie es beheben können?
Jason S
1

Eine Sache, die Ihr Beispiel nicht berücksichtigt, ist die Optimierung. awird in der Schleife gesetzt, aber nie verwendet, und ein Optimierer könnte dies herausfinden. Als solches ist es für den Optimierer legitim, vollständig zu verwerfen a, und in diesem Fall verschwindet jedes undefinierte Verhalten wie das Opfer eines Boojums.

Dies selbst ist natürlich nicht definiert, da die Optimierung nicht definiert ist. :) :)

Graham
quelle
1
Es gibt keinen Grund, eine Optimierung in Betracht zu ziehen, wenn festgestellt wird, ob das Verhalten undefiniert ist.
Keith Thompson
2
Die Tatsache, dass sich das Programm so verhält, wie man annehmen könnte, bedeutet nicht, dass das undefinierte Verhalten "verschwindet". Das Verhalten ist immer noch undefiniert und Sie verlassen sich einfach auf das Glück. Die Tatsache, dass sich das Verhalten des Programms basierend auf den Compileroptionen ändern kann, ist ein starker Indikator dafür, dass das Verhalten undefiniert ist.
Jordan Melo
@JordanMelo Da in vielen der vorherigen Antworten die Optimierung behandelt wurde (und das OP speziell danach gefragt hat), habe ich eine Optimierungsfunktion erwähnt, die in keiner vorherigen Antwort behandelt wurde. Ich wies auch darauf hin, dass, obwohl die Optimierung es entfernen könnte, das Vertrauen in die Optimierung, um auf eine bestimmte Weise zu funktionieren, wieder undefiniert ist. Ich kann es auf keinen Fall empfehlen! :)
Graham
@KeithThompson Sicher, aber das OP fragte speziell nach der Optimierung und ihren Auswirkungen auf das undefinierte Verhalten, das er auf seiner Plattform sehen würde. Dieses spezifische Verhalten kann je nach Optimierung verschwinden. Wie ich in meiner Antwort sagte, würde die Undefiniertheit dies nicht tun.
Graham
0

Da diese Frage C und C ++ mit zwei Tags versehen ist, werde ich versuchen, beide zu beantworten. C und C ++ verfolgen hier unterschiedliche Ansätze.

In C muss die Implementierung nachweisen können, dass das undefinierte Verhalten aufgerufen wird, um das gesamte Programm so zu behandeln, als ob es ein undefiniertes Verhalten hätte. Im OPs-Beispiel erscheint es für den Compiler trivial, dies zu beweisen, und daher ist es so, als ob das gesamte Programm undefiniert wäre.

Wir können dies aus dem Fehlerbericht 109 ersehen, der an seinem Kern fragt:

Wenn jedoch der C-Standard die getrennte Existenz von "undefinierten Werten" erkennt (deren bloße Erstellung kein vollständig "undefiniertes Verhalten" beinhaltet), könnte eine Person, die Compilertests durchführt, einen Testfall wie den folgenden schreiben, und er / sie könnte dies auch erwarten (oder möglicherweise verlangen), dass eine konforme Implementierung diesen Code zumindest ohne "Fehler" kompiliert (und möglicherweise auch ausführen lässt).

int array1[5];
int array2[5];
int *p1 = &array1[0];
int *p2 = &array2[0];

int foo()
{
int i;
i = (p1 > p2); /* Must this be "successfully translated"? */
1/0; /* Must this be "successfully translated"? */
return 0;
}

Die Grundfrage lautet also: Muss der obige Code "erfolgreich übersetzt" werden (was auch immer das bedeutet)? (Siehe die Fußnote zu Unterabschnitt 5.1.1.3.)

und die Antwort war:

Der C-Standard verwendet den Begriff "unbestimmt bewertet" und nicht "undefinierter Wert". Die Verwendung eines unbestimmten Wertobjekts führt zu undefiniertem Verhalten. In der Fußnote zu Unterabschnitt 5.1.1.3 wird darauf hingewiesen, dass eine Implementierung eine beliebige Anzahl von Diagnosen erstellen kann, solange ein gültiges Programm noch korrekt übersetzt wird. Wenn ein Ausdruck, dessen Auswertung zu undefiniertem Verhalten führen würde, in einem Kontext erscheint, in dem ein konstanter Ausdruck erforderlich ist, stimmt das enthaltende Programm nicht genau überein. Wenn außerdem jede mögliche Ausführung eines bestimmten Programms zu einem undefinierten Verhalten führen würde, ist das angegebene Programm nicht streng konform. Eine konforme Implementierung darf ein streng konformes Programm nicht übersehen, nur weil eine mögliche Ausführung dieses Programms zu undefiniertem Verhalten führen würde. Da foo möglicherweise nie aufgerufen wird, muss das angegebene Beispiel von einer konformen Implementierung erfolgreich übersetzt werden.

In C ++ scheint der Ansatz entspannter zu sein und würde darauf hindeuten, dass ein Programm ein undefiniertes Verhalten aufweist, unabhängig davon, ob die Implementierung dies statisch beweisen kann oder nicht.

Wir haben [intro.abstrac] p5, das sagt:

Eine konforme Implementierung, die ein wohlgeformtes Programm ausführt, muss dasselbe beobachtbare Verhalten erzeugen wie eine der möglichen Ausführungen der entsprechenden Instanz der abstrakten Maschine mit demselben Programm und derselben Eingabe. Jedoch , wenn eine solche Ausführung eine undefinierte Operation enthält, stellt dieses Dokument keine Anforderung an die Durchführung dieses Programms mit dem Eingang (auch nicht in bezug auf Vorgänge vor der ersten Operation nicht definiert) ausgeführt wird .

Shafik Yaghmour
quelle
Die Tatsache, dass das Ausführen einer Funktion UB aufrufen würde, kann das Verhalten eines Programms nur dann beeinflussen, wenn eine bestimmte Eingabe gegeben wird, wenn mindestens eine mögliche Ausführung des Programms, wenn diese Eingabe gegeben wird, UB aufrufen würde. Die Tatsache, dass das Aufrufen einer Funktion UB aufrufen würde, verhindert nicht, dass ein Programm ein definiertes Verhalten aufweist, wenn ihm eine Eingabe zugeführt wird, die das Aufrufen der Funktion nicht zulässt.
Supercat
@supercat Ich glaube, das ist meine Antwort, die wir zumindest für C sagen.
Shafik Yaghmour
Ich denke, dasselbe gilt für den zitierten Text zu C ++, da sich der Ausdruck "Jede solche Ausführung" auf Möglichkeiten bezieht, die das Programm mit einer bestimmten gegebenen Eingabe ausführen könnte. Wenn eine bestimmte Eingabe nicht zur Ausführung einer Funktion führen könnte, sehe ich im zitierten Text nichts, was darauf hindeutet, dass irgendetwas in einer solchen Funktion zu UB führen würde.
Supercat
-2

Die beste Antwort ist ein falsches (aber häufiges) Missverständnis:

Undefiniertes Verhalten ist eine Laufzeit- Eigenschaft *. Es kann nicht "Zeitreise"!

Bestimmte Operationen sind (standardmäßig) so definiert, dass sie Nebenwirkungen haben und nicht wegoptimiert werden können. Vorgänge, die E / A ausführen oder auf volatileVariablen zugreifen , fallen in diese Kategorie.

Es gibt jedoch eine Einschränkung: UB kann ein beliebiges Verhalten sein, einschließlich eines Verhaltens, das frühere Operationen rückgängig macht . Dies kann in einigen Fällen ähnliche Konsequenzen haben wie die Optimierung früherer Codes.

Tatsächlich stimmt dies mit dem Zitat in der oberen Antwort überein (Hervorhebung von mir):

Eine konforme Implementierung, die ein wohlgeformtes Programm ausführt, muss dasselbe beobachtbare Verhalten erzeugen wie eine der möglichen Ausführungen der entsprechenden Instanz der abstrakten Maschine mit demselben Programm und derselben Eingabe.
Wenn eine solche Ausführung jedoch eine undefinierte Operation enthält, stellt diese Internationale Norm keine Anforderung an die Implementierung , die dieses Programm mit dieser Eingabe ausführt (nicht einmal in Bezug auf Operationen, die der ersten undefinierten Operation vorausgehen).

Ja, dieses Zitat tut sagen , „nicht einmal im Hinblick auf den Operationen den ersten undefinierten Betrieb vorangestellten“ , aber feststellen , dass dies speziell über Code, wird ausgeführt , nicht nur zusammengestellt.
Schließlich bewirkt undefiniertes Verhalten, das nicht tatsächlich erreicht wird, nichts, und damit die Zeile mit UB tatsächlich erreicht wird, muss der vorhergehende Code zuerst ausgeführt werden!

Ja, sobald UB ausgeführt wird , werden alle Auswirkungen früherer Operationen undefiniert. Bis dahin ist die Ausführung des Programms jedoch genau definiert.

Beachten Sie jedoch, dass alle Ausführungen des Programms, die zu diesem Ereignis führen, auf äquivalente Programme optimiert werden können, einschließlich aller Programme, die vorherige Vorgänge ausführen, deren Auswirkungen dann jedoch nicht mehr ausgeführt werden. Folglich kann der vorhergehende Code immer dann optimiert werden, wenn dies gleichbedeutend damit ist, dass ihre Auswirkungen rückgängig gemacht werden . sonst kann es nicht. Ein Beispiel finden Sie weiter unten.

* Hinweis: Dies ist nicht unvereinbar mit UB, das zur Kompilierungszeit auftritt . Wenn der Compiler tatsächlich nachweisen kann , dass Code UB wird immer für alle Eingänge ausgeführt wird, dann kann UB zu Kompilierung verlängern. Dies setzt jedoch voraus, dass der gesamte vorherige Code schließlich zurückgegeben wird , was eine wichtige Voraussetzung ist. Ein Beispiel / eine Erklärung finden Sie weiter unten.


Beachten Sie dazu, dass der folgende Code gedruckt und auf Ihre Eingabe gewartet werden muss,foo unabhängig von einem darauf folgenden undefinierten Verhalten:

printf("foo");
getchar();
*(char*)1 = 1;

Beachten Sie jedoch auch, dass es keine Garantie gibt, foodie nach dem Auftreten der UB auf dem Bildschirm angezeigt wird, oder dass sich das von Ihnen eingegebene Zeichen nicht mehr im Eingabepuffer befindet. Beide Vorgänge können "rückgängig gemacht" werden, was einen ähnlichen Effekt wie "Zeitreise" von UB hat.

Wenn die getchar()Leitung nicht vorhanden wäre, wäre es legal, die Leitungen genau dann zu optimieren, wenn dies nicht von der Ausgabe zu unterscheiden wäre foound sie dann "nicht mehr zu tun" wäre.

Ob die beiden nicht zu unterscheiden sind oder nicht, hängt vollständig von der Implementierung ab (dh von Ihrem Compiler und Ihrer Standardbibliothek). Können Sie beispielsweise Ihren Thread hier printf blockieren , während Sie darauf warten, dass ein anderes Programm die Ausgabe liest? Oder wird es sofort zurückkehren?

  • Wenn es hier blockieren kann, kann ein anderes Programm das Lesen seiner vollständigen Ausgabe verweigern, und es kann niemals zurückkehren, und folglich kann UB niemals tatsächlich auftreten.

  • Wenn es hier sofort zurückkehren kann, dann wissen wir, dass es zurückkehren muss, und daher ist eine Optimierung nicht zu unterscheiden, wenn es ausgeführt und dann seine Auswirkungen aufgehoben werden.

Da der Compiler weiß, welches Verhalten für seine bestimmte Version von zulässig ist printf, kann er natürlich entsprechend optimieren und printfkann daher in einigen Fällen und nicht in anderen Fällen optimiert werden. Die Rechtfertigung ist jedoch wiederum, dass dies nicht von der UB zu unterscheiden ist, die frühere Operationen nicht ausführt, und nicht, dass der vorherige Code aufgrund von UB "vergiftet" ist.

user541686
quelle
1
Sie verstehen den Standard völlig falsch. Es heißt, dass das Verhalten beim Ausführen des Programms undefiniert ist. Zeitraum. Diese Antwort ist 100% falsch. Der Standard ist sehr klar: Das Ausführen eines Programms mit Eingaben, die an jedem Punkt des naiven Ausführungsflusses UB erzeugen, ist undefiniert.
David Schwartz
@DavidSchwartz: Wenn Sie Ihrer Interpretation zu ihren logischen Schlussfolgerungen folgen, sollten Sie erkennen, dass dies keinen logischen Sinn ergibt. Die Eingabe ist beim Programmstart nicht vollständig festgelegt. Die Eingabe in das Programm (auch das bloße Vorhandensein ) in einer bestimmten Zeile darf bis zu dieser Zeile von allen Nebenwirkungen des Programms abhängen . Daher kann das Programm nicht vermeiden, die Nebenwirkungen zu erzeugen, die vor der UB-Linie auftreten, da dies eine Interaktion mit seiner Umgebung erfordert und daher beeinflusst, ob die UB-Linie überhaupt erreicht wird oder nicht.
user541686
3
Das ist egal. Ja wirklich. Auch hier fehlt Ihnen nur die Vorstellungskraft. Wenn der Compiler beispielsweise feststellen kann, dass kein kompatibler Code den Unterschied erkennen kann, kann er den UB-Code so verschieben, dass der Teil, den UB ausführt, vor den Ausgaben ausgeführt wird, von denen Sie naiv erwarten, dass sie "vorangehen".
David Schwartz
2
@Mehrdad: Vielleicht wäre ein besseres Mittel, um Dinge zu sagen, zu sagen, dass UB nicht über den letzten Punkt hinaus reisen kann, an dem in der realen Welt etwas hätte passieren können, das das Verhalten definiert hätte. Wenn eine Implementierung durch Untersuchen von Eingabepuffern feststellen könnte, dass keiner der nächsten 1000 Aufrufe von getchar () blockiert werden kann, und sie auch bestimmen könnte, dass UB nach dem 1000. Aufruf auftreten würde, wäre es nicht erforderlich, einen von auszuführen die Anrufe. Wenn jedoch eine Implementierung angeben würde, dass die Ausführung ein getchar () nicht übergeben wird, bis alle vorhergehenden Ausgaben ...
supercat
2
... an ein 300-Baud-Terminal geliefert wurde und dass jedes zuvor auftretende Control-C dazu führt, dass getchar () ein Signal auslöst, selbst wenn sich andere Zeichen im Puffer davor befinden, könnte eine solche Implementierung dies nicht Bewegen Sie ein beliebiges UB über den letzten Ausgang vor einem getchar () hinaus. Was schwierig ist, ist zu wissen, in welchem ​​Fall von einem Compiler erwartet werden sollte, dass er den Programmierer durchläuft. Verhaltensgarantien, die eine Bibliotheksimplementierung möglicherweise über die vom Standard vorgeschriebenen hinaus bietet.
Supercat