it-swarm-eu.dev

Philosophie hinter undefiniertem Verhalten

C\C++ - Spezifikationen lassen eine Vielzahl von Verhaltensweisen 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:

Bei meiner Frage geht es nicht darum, was undefiniertes Verhalten ist oder ob es wirklich schlecht ist. Ich kenne die Gefahren und die meisten relevanten undefinierten Verhaltenszitate aus dem Standard. Bitte veröffentlichen Sie keine Antworten darüber, wie schlimm es ist. Bei dieser Frage geht es um die Philosophie, die dahinter steckt, so viele Verhaltensweisen für die Compiler-Implementierung offen zu lassen.

Ich habe einen ausgezeichneter Blog-Beitrag gelesen, der besagt, dass Leistung der Hauptgrund ist. Ich habe mich gefragt, ob die Leistung das einzige Kriterium ist, um dies zuzulassen, oder gibt es andere Faktoren, die die Entscheidung beeinflussen, Dinge für die Compiler-Implementierung offen zu lassen?

Wenn Sie Beispiele dafür haben, wie ein bestimmtes undefiniertes Verhalten dem Compiler genügend Raum zur Optimierung bietet, listen Sie diese bitte auf. Wenn Sie andere Faktoren als die Leistung kennen, unterstützen Sie Ihre Antwort bitte mit ausreichenden Details.

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.

59
Alok Save

Zunächst möchte ich darauf hinweisen, dass, obwohl ich hier nur "C" erwähne, dies auch für C++ gilt.

Der Kommentar, in dem Gödel erwähnt wurde, war teilweise (aber nur teilweise) zutreffend.

Wenn Sie darauf eingehen, ist undefiniertes Verhalten in den C-Standards weitgehend und zeigt nur die Grenze zwischen dem, was der Standard zu definieren versucht, und dem, was er nicht definiert.

Gödels Theoreme (es gibt zwei) besagen grundsätzlich, dass es unmöglich ist, ein mathematisches System zu definieren, das (nach seinen eigenen Regeln) sowohl vollständig als auch konsistent sein kann. Sie können Ihre Regeln so gestalten, 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 beides haben.

Im Fall von etwas wie C trifft dies nicht direkt zu - zum größten Teil hat die "Beweisbarkeit" der Vollständigkeit oder Konsistenz des Systems für die meisten Sprachdesigner keine hohe Priorität. Gleichzeitig wurden sie wahrscheinlich (zumindest bis zu einem gewissen Grad) 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, hat es vielleicht ein bisschen einfacher gemacht, zurückzutreten, ein wenig zu atmen und sich für die Grenzen dessen zu entscheiden, was sie zu definieren versuchen würden.

Auf die Gefahr hin, (erneut) der Arroganz beschuldigt zu werden, würde ich den C-Standard so charakterisieren, dass er (teilweise) von zwei Grundideen bestimmt wird:

  1. Die Sprache sollte eine möglichst große Auswahl an Hardware unterstützen (idealerweise alle "vernünftigen" Hardware bis zu einer angemessenen Untergrenze).
  2. Die Sprache sollte das Schreiben einer möglichst großen Auswahl an Software für die jeweilige Umgebung unterstützen.

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 ein paar einfachen Richtlinien einigermaßen nahe kommt - im Grunde genommen, wenn dies der Fall ist folgt etwas in der allgemeinen Reihenfolge des Von Neumann-Modells und bietet zumindest 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 den Dateien ziemlich genau entspricht, und einen Zeichensatz mit einem bestimmten Mindestzeichensatz haben (91 sind erforderlich).

Das zweite bedeutet, dass es möglich sein sollte, Code zu schreiben, der die Hardware direkt manipuliert, damit Sie Dinge wie Bootloader, Betriebssysteme, eingebettete Software, die ohne Betriebssystem ausgeführt wird usw. schreiben können. Es gibt letztendlich einige Grenzen In dieser Hinsicht enthält fast jedes praktische Betriebssystem, jeder Bootloader usw. wahrscheinlich mindestens ein wenig Bit Code, der in Assemblersprache geschrieben ist. Ebenso ist es wahrscheinlich, dass selbst ein kleines eingebettetes System mindestens eine Art von vorab geschriebenen Bibliotheksroutinen enthält, um den Zugriff auf Geräte auf dem Host-System zu ermöglichen. Obwohl es schwierig ist, eine genaue Grenze zu definieren, besteht die Absicht darin, die Abhängigkeit von einem solchen Code auf ein Minimum zu beschränken.

Das undefinierte Verhalten in der Sprache wird weitgehend von der Absicht bestimmt, dass die Sprache diese Funktionen unterstützt. Mit der Sprache können Sie beispielsweise eine beliebige Ganzzahl in einen Zeiger konvertieren und auf alles zugreifen, was sich gerade an dieser Adresse befindet. Der Standard unternimmt keinen Versuch zu sagen, was passieren wird, wenn Sie dies tun (z. B. kann selbst das Lesen von einigen Adressen äußerlich sichtbare Auswirkungen haben). Gleichzeitig macht es keinen Versuch, Sie daran zu hindern, solche Dinge zu tun, weil Sie brauchen für einige Arten von Software, die Sie in C schreiben können sollen.

Es gibt ein undefiniertes Verhalten, das auch von anderen Designelementen bestimmt wird. Eine weitere Absicht von C besteht beispielsweise darin, eine separate Kompilierung zu unterstützen. Dies bedeutet (zum Beispiel), dass Sie Teile mit einem Linker "verknüpfen" können, der ungefähr dem entspricht, was die meisten von uns als das übliche Modell eines Linkers ansehen. Insbesondere sollte es möglich sein, separat kompilierte Module ohne Kenntnis der Semantik der Sprache zu einem vollständigen Programm zu kombinieren.

Es gibt eine andere Art von undefiniertem Verhalten (das in C++ viel häufiger vorkommt als in C), das einfach 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 hängen von den anderen Anforderungen ab, z. B. für die separate Zusammenstellung. 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 Funktionen entschieden, auch wenn dies bedeutet, dass einige mögliche Probleme nicht diagnostiziert werden können. anstatt die Möglichkeiten einzuschränken, um sicherzustellen, dass alle möglichen Probleme diagnostiziert werden.

Diese Unterschiede in Absicht führen zu den meisten Unterschieden zwischen C und so etwas wie Java oder den CLI-basierten Systemen von Microsoft. Letztere beschränken sich ziemlich explizit darauf, mit vielem zu arbeiten Ein begrenzterer Satz von Hardware oder das Erfordernis von Software, um die spezifischere Hardware zu emulieren, auf die sie abzielen. Sie beabsichtigen auch speziell, zu verhindern jede direkte Manipulation von Hardware, stattdessen müssen Sie etwas wie JNI oder P/Invoke verwenden ( und Code, der in so etwas wie C) geschrieben ist, um überhaupt einen solchen Versuch zu machen.

Wenn wir für einen Moment auf Godels Theoreme zurückkommen, können wir eine Art 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. Dies ist natürlich eine sehr grobe Analogie - ich bezweifle, dass irgendjemand versucht, einen formalen Beweis für entweder interne Konsistenz oder Vollständigkeit in beiden Fällen zu erbringen. Trotzdem passt der allgemeine Begriff - ziemlich eng mit den Entscheidungen, die sie getroffen haben.

49
Jerry Coffin

Das C Begründung erklärt

Die Begriffe nicht spezifiziertes Verhalten, undefiniertes Verhalten und implementierungsdefiniertes Verhalten werden verwendet, um das Ergebnis des Schreibens von Programmen zu kategorisieren, deren Eigenschaften der Standard nicht vollständig beschreibt oder nicht vollständig beschreiben kann. Das Ziel dieser Kategorisierung ist es, eine bestimmte Vielfalt an Implementierungen zu ermöglichen, die es ermöglicht, dass die Qualität der Implementierung eine aktive Kraft auf dem Markt ist, sowie bestimmte beliebte Erweiterungen zuzulassen, ohne das Gütesiegel von zu entfernen Konformität mit dem Standard. Anhang F des Standards katalogisiert die Verhaltensweisen, die in eine dieser drei Kategorien fallen.

Nicht spezifiziertes Verhalten gibt dem Implementierer einen gewissen Spielraum bei der Übersetzung von Programmen. Dieser Spielraum reicht nicht so weit, dass das Programm nicht übersetzt werden kann.

Undefiniertes Verhalten gibt dem Implementierer die Lizenz, bestimmte Programmfehler, die schwer zu diagnostizieren sind, nicht abzufangen. Es werden auch Bereiche mit möglichen konformen Spracherweiterungen identifiziert: Der Implementierer kann die Sprache erweitern, indem er eine Definition des offiziell undefinierten Verhaltens bereitstellt.

Durch die Implementierung definiertes Verhalten gibt einem Implementierer die Freiheit, den geeigneten Ansatz zu wählen, erfordert jedoch, dass diese Auswahl dem Benutzer erklärt wird. Als implementierungsdefiniert bezeichnete Verhaltensweisen sind im Allgemeinen solche, bei denen ein Benutzer basierend auf der Implementierungsdefinition aussagekräftige Codierungsentscheidungen treffen kann. Implementierer sollten dieses Kriterium berücksichtigen, wenn sie entscheiden, wie umfangreich eine Implementierungsdefinition sein soll. Wie bei nicht angegebenem Verhalten ist es keine angemessene Antwort, die Quelle, die das implementierungsdefinierte Verhalten enthält, einfach nicht zu übersetzen.

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 eines undefinierten Verhaltens ermöglicht es einem Programm, nicht portierbare Funktionen zu verwenden, die explizit als solche gekennzeichnet sind ("undefiniertes Verhalten"), ohne fehlerhaft zu werden. Die Begründung stellt fest:

C-Code kann nicht portierbar sein. Obwohl es darum ging, Programmierern die Möglichkeit zu geben, wirklich portable Programme zu schreiben, wollte das Komitee Programmierer nicht zum Schreiben zwingen Um die Verwendung von C als "High-Level-Assembler" auszuschließen: Die Fähigkeit, maschinenspezifischen Code zu schreiben, ist eine der Stärken von C. Es ist dieses Prinzip, das die Unterscheidung zwischen streng konformes Programm und konformes Programm (§1.7).

Und bei 1,7 stellt es fest

Die dreifache Definition der Konformität wird verwendet, um die Anzahl der konformen Programme zu erweitern und zwischen konformen Programmen mit einer einzigen Implementierung und tragbaren konformen Programmen zu unterscheiden.

Ein streng konformes Programm ist ein anderer Begriff für ein maximal portables Programm. Das Ziel ist es, dem Programmierer die Chance zu geben, leistungsstarke C-Programme zu erstellen, die auch sehr portabel sind, ohne perfekt nützliche C-Programme zu beeinträchtigen, die zufällig nicht portabel sind. Also das Adverb streng.

Somit ist dieses kleine schmutzige Programm, das auf GCC einwandfrei funktioniert, immer noch konform!

Die Geschwindigkeitssache ist im Vergleich zu C besonders problematisch. Wenn C++ einige Dinge tun würde, die sinnvoll sein könnten, wie das Initialisieren großer Arrays primitiver Typen, würde es eine Menge Benchmarks für C-Code verlieren. C++ initialisiert also seine eigenen Datentypen, lässt die C-Typen jedoch so, wie sie waren.

Andere undefinierte Verhaltensweisen spiegeln nur die Realität wider. Ein Beispiel ist die Bitverschiebung mit einer Anzahl, die größer als der Typ ist. Das unterscheidet sich tatsächlich zwischen Hardware-Generationen derselben Familie. Wenn Sie eine 16-Bit-App haben, liefert genau dieselbe Binärdatei auf einem 80286 und einem 80386 unterschiedliche Ergebnisse. Der Sprachstandard besagt also, dass wir es nicht wissen!

Einige Dinge werden einfach so gehalten, wie sie waren, wie die Reihenfolge der Bewertung von Unterausdrücken, die nicht spezifiziert sind. Ursprünglich wurde angenommen, dass dies Compiler-Autoren dabei hilft, besser zu optimieren. Heutzutage sind die Compiler gut genug, um es trotzdem herauszufinden, aber die Kosten für das Finden aller Stellen in vorhandenen Compilern, die die Freiheit nutzen, sind einfach zu hoch.

15
Bo Persson

Zum Beispiel müssen Zeigerzugriffe fast undefiniert sein und nicht unbedingt nur aus Leistungsgründen. Auf einigen Systemen wird beispielsweise beim Laden bestimmter Register mit einem Zeiger eine Hardware-Ausnahme generiert. On SPARC Der Zugriff auf ein nicht richtig ausgerichtetes Speicherobjekt führt zu einem Busfehler, auf x86 ist dies jedoch "nur" langsam. In diesen Fällen ist es schwierig, das Verhalten tatsächlich anzugeben, da die zugrunde liegende Hardware den Willen vorschreibt passieren, und C++ ist auf so viele Arten von Hardware portierbar.

Natürlich gibt es dem Compiler auch die Freiheit, architekturspezifisches Wissen zu verwenden. Für ein nicht angegebenes Verhaltensbeispiel kann die Rechtsverschiebung von vorzeichenbehafteten Werten abhängig von der zugrunde liegenden Hardware logisch oder arithmetisch sein, um die Verwendung der verfügbaren Verschiebungsoperation zu ermöglichen und keine Softwareemulation zu erzwingen.

Ich glaube auch, dass es die Arbeit des Compiler-Autors etwas einfacher macht, aber ich kann mich gerade nicht an das Beispiel erinnern. Ich werde es hinzufügen, wenn ich mich an die Situation erinnere.

7
Mark B

Einfach: Geschwindigkeit und Portabilität. Wenn C++ garantiert, dass Sie eine Ausnahme erhalten, wenn Sie einen ungültigen Zeiger de-referenzieren, ist er nicht auf eingebettete Hardware portierbar. Wenn C++ einige andere Dinge wie immer initialisierte Grundelemente garantieren würde, wäre es langsamer, und in der Zeit von Origin of C++ war langsamer eine wirklich, wirklich schlechte Sache.

6
DeadMG

C wurde auf einer Maschine mit 9-Bit-Bytes und ohne Gleitkommaeinheit erfunden. Angenommen, Bytes müssen 9-Bit-Bytes und 18-Bit-Wörter sein, und Floats sollten unter Verwendung von Aritmatik vor IEEE754 implementiert werden.

4
Martin Beckett

Ich denke nicht, dass der erste Grund für UB darin bestand, dem Compiler Raum für Optimierungen zu lassen, sondern nur die Möglichkeit, die offensichtliche Implementierung für die Ziele zu einer Zeit zu verwenden, in der Architekturen vielfältiger waren als jetzt (denken Sie daran, wenn C auf a entworfen wurde PDP-11, das eine etwas vertraute Architektur hat, der erste Port war Honeywell 635 was weit weniger bekannt ist - Wort adressierbar, mit 36-Bit-Wörtern, 6 oder 9-Bit-Bytes, 18-Bit-Adressen. Nun, zumindest wurde das 2er-Komplement verwendet. Wenn jedoch keine starke Optimierung ein Ziel war, umfasst die offensichtliche Implementierung nicht das Hinzufügen von Laufzeitprüfungen auf Überlauf, die Anzahl der Verschiebungen über die Registergröße, die in Ausdrücken Aliase enthalten, die mehrere Werte ändern.

Eine andere berücksichtigte Sache war die einfache Implementierung. Ein C-Compiler war zu dieser Zeit mehrere Durchgänge mit mehreren Prozessen, da mit einem Prozesshandle nicht alles möglich gewesen wäre (das Programm wäre zu groß gewesen). Eine strenge Kohärenzprüfung war nicht möglich - insbesondere, wenn mehrere CU beteiligt waren. (Ein anderes Programm als die C-Compiler, lint, wurde dafür verwendet).

4
AProgrammer

Einer der frühen klassischen Fälle war die Ganzzahladdition. Bei einigen der verwendeten Prozessoren würde dies einen Fehler verursachen, bei anderen wird nur ein Wert (wahrscheinlich der entsprechende modulare Wert) verwendet. Die Angabe eines der beiden Fälle würde bedeuten, dass Programme für Maschinen mit dem ungünstigen arithmetischen Stil zusätzlichen Code, einschließlich einer bedingten Verzweigung, für etwas ähnlich Ähnliches wie die Ganzzahladdition haben müssten.

3
David Thornley

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 und die Tatsache widerspiegeln, dass es zum Zeitpunkt der Veröffentlichung eines Standards eine geben wird große Anzahl von Implementierungen auf vielen verschiedenen Hardware. Ein Standard, der notwendiges Verhalten verbietet, würde entweder ignoriert oder eine konkurrierende Normungsorganisation hervorbringen.

2
jmoreno

Einige Verhaltensweisen können nicht mit vernünftigen 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 zuzulassen, dass keine Zuordnungsfunktion ihn zurückgibt). Nicht nur ein solches Auswendiglernen wäre übertrieben, sondern würde bei einem lang laufenden Programm dazu führen, dass die zulässigen Zeigerwerte nicht mehr ausreichen.

1
Tadeusz Kopec

In der Vergangenheit hatte undefiniertes Verhalten zwei Hauptziele:

  1. Um zu vermeiden, dass Compilerautoren Code generieren müssen, um Bedingungen zu verarbeiten, die niemals auftreten sollten.

  2. Um die Möglichkeit zu berücksichtigen, dass Implementierungen ohne Code zur expliziten Behandlung solcher Bedingungen verschiedene Arten von "natürlichen" Verhaltensweisen aufweisen können, die in einigen Fällen nützlich wären.

Als einfaches Beispiel wird auf einigen Hardwareplattformen der Versuch, zwei positiv vorzeichenbehaftete Ganzzahlen zu addieren, deren Summe zu groß ist, um in eine vorzeichenbehaftete Ganzzahl zu passen, eine bestimmte negativ vorzeichenbehaftete Ganzzahl ergeben. 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 Compilerautoren die Philosophie vertreten, dass Compiler alles daran setzen sollten, Bedingungen zu finden, die undefiniertes Verhalten hervorrufen würden, und unter der Annahme, dass solche Situationen niemals auftreten könnten, daraus erweiterte Schlussfolgerungen zu ziehen. Auf einem System mit 32-Bit int wird daher Code wie folgt angegeben:

uint32_t foo(uint16_t q, int *p)
{
  if (q > 46340)
    *p++;
  return q*q;
}

der C-Standard würde es dem Compiler ermöglichen zu sagen, dass, wenn q 46341 oder größer ist, der Ausdruck q * q ein Ergebnis ergibt, das zu groß ist, um in ein int zu passen, was folglich ein undefiniertes Verhalten und folglich den Compiler verursacht wäre berechtigt anzunehmen, dass dies nicht passieren kann, und wäre daher nicht verpflichtet, *p zu erhöhen, 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 funktionieren, zu vernünftigen Ergebnissen geführt hätte Integer-Überlauf (Trapping kann hässlich sein, wäre aber zumindest sinnvoll) und verwandelt es in Code, der sich möglicherweise unsinnig verhält.

1
supercat

Ich gebe Ihnen ein Beispiel, bei dem es so gut wie keine vernünftige Wahl gibt, außer undefiniertem Verhalten. Im Prinzip könnte jeder Zeiger auf den Speicher zeigen, der eine Variable enthält, mit der 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. Der Betrieb ohne Speicher ist ein Nichtstarter.

Dies gibt Ihnen grundsätzlich zwei Möglichkeiten:

1) Löschen Sie alles aus den Registern, bevor Sie über einen Zeiger darauf zugreifen, nur für den Fall, dass der Zeiger auf den Speicher dieser bestimmten Variablen zeigt. Laden Sie dann alles Notwendige zurück in das Register, für den Fall, dass die Werte über den Zeiger geändert wurden.

2) Haben Sie eine Reihe von Regeln, wann ein Zeiger eine Variable aliasen darf und wann der Compiler annehmen darf, dass ein Zeiger keine Variable aliasisiert.

C entscheidet sich für Option 2, weil 1 für die Leistung schrecklich wäre. Was passiert dann, wenn ein Zeiger eine Variable so aliasisiert, wie es die C-Regeln verbieten? Da der Effekt davon abhängt, ob der Compiler die Variable tatsächlich in einem Register gespeichert hat, kann der C-Standard bestimmte Ergebnisse nicht definitiv garantieren.

0
David Schwartz