C \ C ++ - Spezifikationen lassen eine Vielzahl von Verhalten offen, die Compiler auf ihre eigene Weise implementieren können. Es gibt eine Reihe von Fragen, die hier immer wieder gestellt werden, und wir haben einige ausgezeichnete Beiträge dazu:
- https://stackoverflow.com/questions/367633/what-are-all-the-common-undefined-behaviour-that-ac-programmer-should-know-abo
- https://stackoverflow.com/questions/4105120/what-is-undefined-behavior
- https://stackoverflow.com/questions/4176328/undefined-behavior-and-sequence-points
Meine Frage ist nicht, was undefiniertes Verhalten ist, oder ist es wirklich schlecht. Ich kenne die Gefahren und die meisten relevanten undefinierten Verhaltensausdrücke aus dem Standard. Bitte veröffentlichen Sie keine Antworten darüber, wie schlimm es ist. Bei dieser Frage geht es um die Philosophie, dass so viele Verhaltensweisen für die Compiler-Implementierung offen bleiben.
Ich habe einen ausgezeichneten Blog-Beitrag gelesen , in dem die Leistung als Hauptgrund genannt wird. Ich habe mich gefragt, ob die Leistung das einzige Kriterium ist, um dies zuzulassen, oder ob andere Faktoren die Entscheidung beeinflussen, die Dinge für die Compiler-Implementierung offen zu lassen.
Wenn Sie Beispiele dafür haben, wie ein bestimmtes undefiniertes Verhalten dem Compiler ausreichend Optimierungsspielraum bietet, listen Sie diese bitte auf. Wenn Sie andere Faktoren als die Leistung kennen, stützen Sie Ihre Antwort bitte mit ausreichenden Einzelheiten.
Wenn Sie die Frage nicht verstehen oder nicht über ausreichende Beweise / Quellen verfügen, um Ihre Antwort zu stützen, veröffentlichen Sie bitte keine allgemein spekulierenden Antworten.
quelle
Antworten:
Ich stelle zunächst fest, dass, obwohl ich hier nur "C" erwähne, dasselbe auch für C ++ gilt.
Der Kommentar, in dem Godel erwähnt wurde, war teilweise (aber nur teilweise) zutreffend.
Wenn Sie es, nicht definiertes Verhalten in den C - Normen wird herunterkommen weitgehend unter Hinweis darauf , nur die Grenze zwischen dem, was die Standard - Versuche zu definieren, und was nicht.
Gödels Theoreme (es gibt zwei) besagen grundsätzlich, dass es unmöglich ist, ein mathematisches System zu definieren, das (durch seine eigenen Regeln) als vollständig und konsistent nachgewiesen werden kann. Sie können Ihre Regeln so formulieren, dass sie vollständig sind (der Fall, mit dem er sich befasst hat, waren die "normalen" Regeln für natürliche Zahlen), oder Sie können es ermöglichen, ihre Konsistenz zu beweisen, aber Sie können nicht beide haben.
Bei etwas wie C gilt dies nicht direkt - für die meisten Sprachdesigner hat die "Beweisbarkeit" der Vollständigkeit oder Konsistenz des Systems zum größten Teil keine hohe Priorität. Zugleich wurden sie wahrscheinlich (zumindest teilweise) dadurch beeinflusst, dass sie wussten, dass es nachweislich unmöglich ist, ein "perfektes" System zu definieren - eines, das nachweislich vollständig und konsistent ist. Zu wissen, dass so etwas unmöglich ist, könnte es ein bisschen einfacher gemacht haben, einen Schritt zurückzutreten, ein wenig zu atmen und die Grenzen dessen zu bestimmen, was sie zu definieren versuchen würden.
Unter der Gefahr, (erneut) der Arroganz beschuldigt zu werden, würde ich den C-Standard als (teilweise) von zwei Grundgedanken bestimmt bezeichnen:
Das erste bedeutet, dass, wenn jemand eine neue CPU definiert, es möglich sein sollte, eine gute, solide und brauchbare Implementierung von C dafür bereitzustellen, solange das Design zumindest einigermaßen in der Nähe einiger einfacher Richtlinien liegt - im Grunde genommen, wenn dies der Fall ist Es folgt etwas der allgemeinen Ordnung des Von Neumann-Modells und bietet mindestens eine angemessene Mindestmenge an Speicher, die ausreichen sollte, um eine C-Implementierung zu ermöglichen. Für eine "gehostete" Implementierung (eine, die auf einem Betriebssystem ausgeführt wird) müssen Sie einen Begriff unterstützen, der Dateien ziemlich genau entspricht, und über einen Zeichensatz mit einer bestimmten Mindestanzahl von Zeichen verfügen (91 sind erforderlich).
Die zweite Mittel soll es möglich sein , Code zu schreiben, die Hardware direkt manipuliert, so können Sie Dinge wie Bootloader, Betriebssysteme, Embedded - Software schreiben , die ohne O läuft, usw. Es gibt schließlich einige Grenzen in dieser Hinsicht so fast jeder praktisches Betriebssystem, Bootloader, usw., ist wahrscheinlich zumindest eine enthalten wenig in Assembler geschrieben Stück Code. In ähnlicher Weise wird wahrscheinlich sogar ein kleines eingebettetes System mindestens eine Art von vorab geschriebenen Bibliotheksroutinen enthalten, um den Zugriff auf Geräte auf dem Hostsystem zu ermöglichen. Obwohl es schwierig ist, eine genaue Grenze zu definieren, besteht die Absicht darin, die Abhängigkeit von solchem Code auf ein Minimum zu beschränken.
Das undefinierte Verhalten in der Sprache wird hauptsächlich durch die Absicht bestimmt, dass die Sprache diese Funktionen unterstützt. Mit der Sprache können Sie beispielsweise eine beliebige Ganzzahl in einen Zeiger umwandeln und auf alles zugreifen, was sich an dieser Adresse befindet. Der Standard unternimmt keinen Versuch zu sagen, was passieren wird, wenn Sie dies tun (z. B. kann das Lesen von einigen Adressen äußerlich sichtbare Auswirkungen haben). Zugleich macht es keinen Versuch zu verhindern , dass Sie solche Dinge zu tun, weil Sie brauchen für einige Arten von Software , die Sie angeblich sind in der Lage sein , in C zu schreiben
Es gibt auch undefiniertes Verhalten, das von anderen Designelementen gesteuert wird. Eine weitere Absicht von C ist beispielsweise, die separate Kompilierung zu unterstützen. Dies bedeutet (zum Beispiel), dass Sie Teile mit einem Linker "verknüpfen" können, der in etwa dem entspricht, was die meisten von uns als das übliche Modell eines Linkers ansehen. Insbesondere soll es möglich sein, separat kompilierte Module ohne Kenntnis der Semantik der Sprache zu einem vollständigen Programm zusammenzufassen.
Es gibt eine andere Art von undefiniertem Verhalten (das in C ++ weitaus häufiger vorkommt als in C), das nur aufgrund der Grenzen der Compilertechnologie auftritt - Dinge, von denen wir im Grunde wissen, dass sie Fehler sind, und die der Compiler wahrscheinlich als Fehler diagnostizieren soll. Angesichts der derzeitigen Grenzen der Compilertechnologie ist es jedoch zweifelhaft, ob sie unter allen Umständen diagnostiziert werden können. Viele davon werden von den anderen Anforderungen bestimmt, z. B. für die getrennte Kompilierung. Daher geht es hauptsächlich darum, widersprüchliche Anforderungen auszugleichen. In diesem Fall hat sich das Komitee im Allgemeinen für die Unterstützung größerer Fähigkeiten entschieden, auch wenn dies bedeutet, dass einige mögliche Probleme nicht diagnostiziert wurden. anstatt die Möglichkeiten einzuschränken, um sicherzustellen, dass alle möglichen Probleme diagnostiziert werden.
Diese Absichtsunterschiede führen zu den meisten Unterschieden zwischen C und Java oder den CLI-basierten Systemen von Microsoft. Letztere beschränken sich ausdrücklich auf die Arbeit mit einer viel engeren Hardware oder erfordern Software, um die spezifischere Hardware zu emulieren, auf die sie abzielen. Sie beabsichtigen außerdem ausdrücklich, direkte Manipulationen an der Hardware zu verhindern. Stattdessen müssen Sie JNI oder P / Invoke (und in C geschriebenen Code) verwenden, um einen solchen Versuch zu unternehmen.
Wenn wir für einen Moment auf Godels Theoreme zurückkommen, können wir eine Parallele ziehen: Java und CLI haben sich für die "intern konsistente" Alternative entschieden, während C sich für die "vollständige" Alternative entschieden hat. Natürlich ist dies eine sehr grobe Analogie - jemand hat versucht , einen formalen Beweis Ich bezweifle , entweder interne Konsistenz oder Vollständigkeit in jedem Fall. Trotzdem passt der allgemeine Begriff ziemlich gut zu den Entscheidungen, die sie getroffen haben.
quelle
Das Grundprinzip erklärt
Wichtig ist auch der Nutzen für Programme, nicht nur der Nutzen für Implementierungen. Ein Programm, das von undefiniertem Verhalten abhängt, kann immer noch konform sein , wenn es von einer konformen Implementierung akzeptiert wird. Das Vorhandensein von undefiniertem Verhalten ermöglicht es einem Programm, nicht tragbare Funktionen zu verwenden, die ausdrücklich als solche gekennzeichnet sind ("undefiniertes Verhalten"), ohne dass dies zu Abweichungen führt. Die Begründung stellt fest:
Und bei 1,7 merkt es
Somit ist dieses kleine schmutzige Programm, das auf GCC einwandfrei funktioniert, immer noch konform !
quelle
Die Geschwindigkeit ist im Vergleich zu C besonders problematisch. Wenn C ++ einige Dinge ausführt, die möglicherweise sinnvoll sind, z. B. das Initialisieren großer Arrays primitiver Typen, geht eine Menge Benchmarks gegenüber C-Code verloren. Daher initialisiert C ++ seine eigenen Datentypen, lässt die C-Typen jedoch unverändert.
Anderes undefiniertes Verhalten spiegelt nur die Realität wider. Ein Beispiel ist die Bitverschiebung mit einer höheren Anzahl als dem Typ. Das unterscheidet sich tatsächlich zwischen Hardware-Generationen derselben Familie. Wenn Sie eine 16-Bit-App haben, führt die exakt gleiche Binärdatei auf einem 80286 und einem 80386 zu unterschiedlichen Ergebnissen. Der Sprachstandard besagt also, dass wir es nicht wissen!
Einige Dinge bleiben einfach so wie sie waren, wie die Reihenfolge der Auswertung von Unterausdrücken, die nicht spezifiziert sind. Ursprünglich wurde davon ausgegangen, dass dies Compiler-Autoren bei der Optimierung hilft. Heutzutage sind die Compiler gut genug, um das herauszufinden, aber die Kosten für das Auffinden aller Stellen in vorhandenen Compilern, die die Freiheit ausnutzen, sind einfach zu hoch.
quelle
Beispielsweise müssen Zeigerzugriffe fast undefiniert sein und nicht unbedingt nur aus Leistungsgründen. Beispielsweise wird auf einigen Systemen eine Hardwareausnahme generiert, wenn bestimmte Register mit einem Zeiger geladen werden. Bei SPARC-Zugriffen auf ein nicht korrekt ausgerichtetes Speicherobjekt wird ein Busfehler verursacht, bei x86 jedoch "nur" langsam. In solchen Fällen ist es schwierig, das Verhalten tatsächlich anzugeben, da die zugrunde liegende Hardware bestimmt, was passieren soll, und C ++ auf so viele Hardwaretypen portierbar ist.
Natürlich gibt es dem Compiler auch die Freiheit, architekturspezifisches Wissen zu nutzen. Für ein nicht angegebenes Verhaltensbeispiel kann die Verschiebung der vorzeichenbehafteten Werte nach rechts in Abhängigkeit von der zugrunde liegenden Hardware logisch oder arithmetisch sein, um die Verwendung der jeweils verfügbaren Verschiebeoperation zu ermöglichen und die Software-Emulation nicht zu erzwingen.
Ich glaube auch, dass es die Arbeit des Compilers etwas erleichtert, aber ich kann mich gerade nicht an das Beispiel erinnern. Ich werde es hinzufügen, wenn ich mich an die Situation erinnere.
quelle
Einfach: Geschwindigkeit und Portabilität. Wenn C ++ garantiert, dass Sie eine Ausnahme erhalten, wenn Sie einen ungültigen Zeiger de-referenzieren, wäre er nicht auf eingebettete Hardware übertragbar. Wenn C ++ einige andere Dinge garantiert, wie beispielsweise immer initialisierte Primitive, dann wäre es langsamer, und in der Entstehungszeit von C ++ war langsamer eine wirklich, wirklich schlechte Sache.
quelle
C wurde auf einer Maschine mit 9-Bit-Bytes und ohne Gleitkommaeinheit erfunden. Angenommen, die Bytes sollten 9-Bit-Bytes und die Wörter 18-Bit-Bytes sein und die Gleitkommazahlen sollten unter Verwendung von Pre-IEEE754-Aritmatic implementiert werden.
quelle
Ich glaube nicht, dass das erste Argument für UB darin bestand, dem Compiler Raum für die Optimierung zu lassen, sondern nur die Möglichkeit, die offensichtliche Implementierung für die Ziele in einer Zeit zu verwenden, in der die Architekturen vielfältiger waren als jetzt (denken Sie daran, wenn C auf a ausgelegt war) PDP-11, das eine etwas vertraute Architektur hat, war der erste Port für Honeywell 635, der weitaus weniger bekannt ist - wortadressierbar, mit 36-Bit-Wörtern, 6 oder 9-Bit-Bytes, 18-Bit-Adressen ... nun, zumindest wurden 2er verwendet ergänzen). Wenn jedoch keine umfassende Optimierung angestrebt wurde, umfasst die offensichtliche Implementierung nicht das Hinzufügen von Laufzeitprüfungen auf Überlauf, die Anzahl der Verschiebungen über die Registergröße, die Aliase in Ausdrücken sind, die mehrere Werte ändern.
Eine andere Sache, die berücksichtigt wurde, war die einfache Implementierung. AC-Compiler war zu der Zeit mehrere Durchläufe mit mehreren Prozessen, weil mit einem Prozesshandle nicht alles möglich gewesen wäre (das Programm wäre zu groß gewesen). Schwere Kohärenzprüfungen zu beantragen, war nicht möglich - insbesondere, wenn es sich um mehrere CU handelte. (Ein anderes Programm als die C-Compiler, Lint, wurde dafür verwendet).
quelle
i
undn
, so dassn < INT_BITS
undi*(1<<n)
nicht überlaufen würde, würde ich es alsi<<=n;
klarer betrachten alsi=(unsigned)i << n;
; Auf vielen Plattformen wäre es schneller und kleiner alsi*=(1<<N);
. Was bringt es, wenn Compiler es verbieten?Einer der frühen klassischen Fälle war eine Ganzzahladdition. Bei einigen der verwendeten Prozessoren würde dies einen Fehler verursachen, und bei anderen würde der Wert einfach fortgesetzt (wahrscheinlich der entsprechende modulare Wert). Wenn Sie einen der beiden Fälle angeben, bedeutet dies, dass Programme für Computer mit dem ungünstigen arithmetischen Stil zusätzlichen Code benötigen, einschließlich eines bedingten Zweigs, für etwas, das so ähnlich ist wie die Ganzzahladdition.
quelle
int
16 Bit und vorzeichenerweiterte Verschiebungen teuer sind, unter(uchar1*uchar2) >> 4
Verwendung einer nicht vorzeichenerweiterten Verschiebung rechnen . Leider erweitern einige Compiler die Schlussfolgerungen nicht nur auf Ergebnisse, sondern auch auf Operanden.Ich würde sagen, es ging weniger um Philosophie als um Realität - C war schon immer eine plattformübergreifende Sprache, und der Standard muss dies widerspiegeln und die Tatsache, dass es zum Zeitpunkt der Veröffentlichung eines Standards einen geben wird große Anzahl von Implementierungen auf vielen verschiedenen Hardware. Ein Standard, der notwendiges Verhalten verbietet, würde entweder missachtet oder zu einer konkurrierenden Normungsorganisation führen.
quelle
Einige Verhaltensweisen können nicht mit angemessenen Mitteln definiert werden. Ich meine den Zugriff auf einen gelöschten Zeiger. Die einzige Möglichkeit, dies zu erkennen, besteht darin, den Zeigerwert nach dem Löschen zu sperren (seinen Wert irgendwo zu speichern und keine Zuweisungsfunktion mehr zuzulassen, um ihn zurückzugeben). Nicht nur ein solches Auswendiglernen wäre übertrieben, sondern ein Programm mit langer Laufzeit würde auch dazu führen, dass die zulässigen Zeigerwerte nicht mehr ausreichen.
quelle
weak_ptr
und alle Verweise auf einen Zeiger aufheben, derdelete
d ... Oh, Moment, wir nähern uns der Garbage Collection: /boost::weak_ptr
Die Implementierung von ist eine ziemlich gute Vorlage für dieses Verwendungsmuster. Anstattweak_ptrs
extern zu verfolgen und aufzuheben , trägt aweak_ptr
nur zurshared_ptr
schwachen Zählung des Zeigers bei, und die schwache Zählung ist im Grunde eine Nachzählung für den Zeiger selbst. Somit können Sie das aufheben,shared_ptr
ohne es sofort löschen zu müssen. Es ist nicht perfekt (Sie können immer noch eine Menge abgelaufenerweak_ptr
Aktien haben, die den Basiswertshared_count
ohne triftigen Grund beibehalten ), aber es ist zumindest schnell und effizient.Ich gebe Ihnen ein Beispiel, in dem es so gut wie keine vernünftige Wahl gibt als undefiniertes Verhalten. Im Prinzip könnte jeder Zeiger auf den Speicher verweisen, der eine Variable enthält, mit einer kleinen Ausnahme von lokalen Variablen, von denen der Compiler wissen kann, dass ihre Adresse nie vergeben wurde. Um jedoch eine akzeptable Leistung auf einer modernen CPU zu erzielen, muss ein Compiler Variablenwerte in Register kopieren. Wenn nicht genügend Arbeitsspeicher zur Verfügung steht, ist dies kein Starter.
Dies gibt Ihnen grundsätzlich zwei Möglichkeiten:
1) Leeren Sie alle Register, bevor Sie auf einen Zeiger zugreifen, für den Fall, dass der Zeiger auf den Speicher dieser bestimmten Variablen verweist. Laden Sie dann alles Notwendige zurück in das Register, für den Fall, dass die Werte über den Zeiger geändert wurden.
2) Stellen Sie Regeln auf, wann ein Zeiger eine Variable als Alias verwenden darf und wann der Compiler davon ausgehen darf, dass ein Zeiger keine Variable als Alias verwendet.
C entscheidet sich für Option 2, da 1 für die Leistung schrecklich wäre. Aber was passiert dann, wenn ein Zeiger eine Variable auf eine Weise aliasiert, die die C-Regeln verbieten? Da der Effekt davon abhängt, ob der Compiler die Variable tatsächlich in einem Register gespeichert hat, gibt es für den C-Standard keine Möglichkeit, bestimmte Ergebnisse definitiv zu garantieren.
quelle
foo
auf 42 setzt und dann eine Methode aufruft, die einen unrechtmäßig geänderten Zeiger zum Setzenfoo
auf 44 verwendet, kann ich den Vorteil sehen, zu sagen, dass derfoo
Versuch, ihn zu lesen , bis zum nächsten "legitimen" Schreiben legitim sein kann ergeben 42 oder 44, und ein Ausdruck wiefoo+foo
könnte sogar 86 ergeben, aber ich sehe weitaus weniger Vorteile darin, dem Compiler zu erlauben, erweiterte und sogar rückwirkende Schlussfolgerungen zu ziehen, wodurch Undefiniertes Verhalten, dessen plausibles "natürliches" Verhalten alle harmlos gewesen wäre, in eine Lizenz umgewandelt wird unsinnigen Code zu generieren.In der Vergangenheit hatte undefiniertes Verhalten zwei Hauptziele:
Um zu vermeiden, dass Compiler-Autoren Code generieren müssen, um Bedingungen zu handhaben, die niemals auftreten sollten.
Um die Möglichkeit zu berücksichtigen, dass Implementierungen in Abwesenheit von Code, um solche Bedingungen explizit zu behandeln, verschiedene Arten von "natürlichen" Verhaltensweisen aufweisen können, die in einigen Fällen nützlich wären.
Ein einfaches Beispiel: Auf einigen Hardwareplattformen führt der Versuch, zwei Ganzzahlen mit positivem Vorzeichen zu addieren, deren Summe zu groß ist, um in eine Ganzzahl mit Vorzeichen zu passen, zu einer bestimmten Ganzzahl mit negativem Vorzeichen. Bei anderen Implementierungen wird eine Prozessorfalle ausgelöst. Damit der C-Standard eines der beiden Verhaltensweisen vorschreibt, müssten Compiler für Plattformen, deren natürliches Verhalten vom Standard abweicht, zusätzlichen Code generieren, um das richtige Verhalten zu erzielen - Code, der möglicherweise teurer ist als der Code für die eigentliche Addition. Schlimmer noch, es würde bedeuten, dass Programmierer, die das "natürliche" Verhalten wollten, noch mehr zusätzlichen Code hinzufügen müssten, um dies zu erreichen (und dieser zusätzliche Code wäre wiederum teurer als das Hinzufügen).
Leider haben einige Compiler-Autoren die Philosophie vertreten, dass Compiler alles daran setzen sollten, Bedingungen zu finden, die Undefiniertes Verhalten hervorrufen, und unter der Annahme, dass solche Situationen niemals auftreten könnten, erweiterte Schlussfolgerungen daraus zu ziehen. Auf einem 32-Bit-System lautet der
int
gegebene Code also wie folgt:Der C-Standard würde dem Compiler erlauben zu sagen, dass wenn q 46341 oder größer ist, der Ausdruck q * q ein Ergebnis liefert, das zu groß ist, um in ein
int
undefiniertes Verhalten zu passen. Infolgedessen wäre der Compiler berechtigt, dies anzunehmen kann nicht passieren und müsste daher nicht erhöht werden,*p
wenn dies der Fall ist. Wenn der aufrufende Code*p
als Indikator dafür verwendet, dass die Ergebnisse der Berechnung verworfen werden sollen, kann die Optimierung dazu führen, dass Code verwendet wird, der auf Systemen, die auf nahezu jede erdenkliche Weise mit ganzzahligem Überlauf arbeiten, zu vernünftigen Ergebnissen geführt hätte (möglicherweise Trapping) hässlich, wäre aber zumindest vernünftig) und wandelte es in Code um, der sich unsinnig verhalten kann.quelle
Effizienz ist die übliche Ausrede, aber ungeachtet der Ausrede ist undefiniertes Verhalten eine schreckliche Idee für Portabilität. Tatsächlich werden undefinierte Verhaltensweisen zu unbestätigten Annahmen.
quelle