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 a
es 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.)
c++
c
undefined-behavior
integer-overflow
jcoder
quelle
quelle
a
a
Antworten:
Wenn Sie an einer rein theoretischen Antwort interessiert sind, erlaubt der C ++ - Standard undefiniertes Verhalten für "Zeitreisen":
Wenn Ihr Programm undefiniertes Verhalten enthält, ist das Verhalten Ihres gesamten Programms undefiniert.
quelle
sneeze()
Funktion selbst für nichts in der KlasseDemon
(von der die Nasensorte eine Unterklasse ist) undefiniert , was das Ganze sowieso kreisförmig macht.printf
nicht zurückgegeben wird, aber wenn zurückgegebenprintf
wird, kann das undefinierte Verhalten Probleme verursachen, bevorprintf
es aufgerufen wird. Daher Zeitreise.printf("Hello\n");
und dann kompiliert die nächste Zeile alsundoPrintf(); launchNuclearMissiles();
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:
LD_PRELOAD
Tricks unter Unixen verwendetDies 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 .
quelle
argc
der Fall , wenn Sie ihn als Schleifenzahl verwendet hätten,argc=1
kein UB, und der Compiler wäre gezwungen, damit umzugehen.i
nicht mehr alsN
mal inkrementiert werden kann und daher sein Wert begrenzt ist.f(good);
etwas X tut undf(bad);
undefiniertes Verhalten aufruft, dannf(good);
wird garantiert, dass ein Programm, das nur aufruft , Xf(good); f(bad);
if(foo) f(good); else f(bad);
, wird ein intelligenter Compiler den Vergleich und die Produktion und eine bedingungslose wegwerfenfoo(good)
.Ein aggressiv optimierter C- oder C ++ - Compiler, der auf 16 Bit
int
abzielt, weiß, dass das Verhalten beim Hinzufügen1000000000
zu einemint
Typ 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
int
s? 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 dahera
irgendwann überlaufen. So kann es wieder die Ausgabe auf optimierenint 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.
quelle
int
es sich um 16-Bit handelt, erfolgt die Addition inlong
(da der Literaloperand einen Typ hatlong
), wo er genau definiert ist, und wird dann durch eine implementierungsdefinierte Konvertierung zurück in konvertiertint
.printf
wird durch den Standard definiert, um immer zurückzukehrenTechnisch 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.
quelle
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?Um zu verstehen, warum undefiniertes Verhalten "Zeitreisen" kann, wie @TartanLlama es angemessen ausdrückt , werfen wir einen Blick auf die "Als-ob" -Regel:
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.
quelle
Angenommen, es
int
handelt 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.quelle
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:
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: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.
quelle
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)Ü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:
Ein Compiler könnte dies in Folgendes umwandeln:
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:
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.
quelle
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.
quelle
Eine Sache, die Ihr Beispiel nicht berücksichtigt, ist die Optimierung.
a
wird 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 verwerfena
, 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. :) :)
quelle
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:
und die Antwort war:
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:
quelle
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
volatile
Variablen 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):
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:Beachten Sie jedoch auch, dass es keine Garantie gibt,
foo
die 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ärefoo
und 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 undprintf
kann 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.quelle