Philosophie hinter undefiniertem Verhalten


59

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:

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.


7
Wer hat jemals von einem deterministischen Computer gehört?
Sova

1
Wie die ausgezeichnete Antwort von litb, programmers.stackexchange.com/a/99741/192238, zeigt, scheinen Titel und Text dieser Frage ein wenig unpassend zu sein: "Verhaltensweisen, die Compilern auf ihre eigene Weise offenstehen", werden normalerweise als implementierungsdefiniert bezeichnet . Sicher, die tatsächliche UB darf vom Implementierungsautor definiert werden, aber meistens stören sie nicht (und optimieren alles weg usw.)
underscore_d

Antworten:


49

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:

  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 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.


25
Ich denke, Gödels Theoreme sind ein roter Hering. Sie befassen sich damit, ein System aus seinen eigenen Axiomen zu beweisen, was hier nicht der Fall ist: C muss nicht in C angegeben werden. Es ist durchaus möglich, eine vollständig angegebene Sprache zu haben (betrachten Sie eine Turing-Maschine).
Poolie

9
Tut mir leid, aber ich fürchte, Sie haben Godels Theoreme völlig missverstanden. Sie befassen sich mit der Unmöglichkeit, alle wahren Aussagen in einem konsistenten Logiksystem zu beweisen; In Bezug auf das Rechnen entspricht das Unvollständigkeitstheorem der Aussage, dass es Probleme gibt, die von keinem Programm gelöst werden können - Probleme sind analog zu wahren Aussagen, Programmen zu Beweisen und dem Rechenmodell zum Logiksystem. Es hat überhaupt keine Verbindung zu undefiniertem Verhalten. Eine Erklärung der Analogie finden Sie hier: scottaaronson.com/blog/?p=710 .
Alex ten Brink

5
Ich sollte beachten, dass eine Von Neumann-Maschine für eine C-Implementierung nicht erforderlich ist. Es ist durchaus möglich (und nicht einmal sehr schwierig), eine C-Implementierung für eine Harvard-Architektur zu entwickeln (und ich wäre nicht überrascht, wenn es viele solcher Implementierungen auf eingebetteten Systemen
gäbe

1
Leider bringt die moderne C-Compiler-Philosophie UB auf ein völlig neues Niveau. Sogar in Fällen, in denen ein Programm darauf vorbereitet war, mit fast allen plausiblen "natürlichen" Konsequenzen einer bestimmten Form von undefiniertem Verhalten umzugehen, und diejenigen, mit denen es sich nicht befassen konnte, zumindest erkennbar wären (z. B. Überlauf von gefangenen Ganzzahlen), wird die neue Philosophie bevorzugt Umgehen von Code, der ohne UB nicht ausgeführt werden konnte, und Umwandeln von Code, der sich bei den meisten Implementierungen korrekt verhalten hätte, in Code, der "effizienter", aber einfach falsch ist.
Supercat

20

Das Grundprinzip erklärt

Die Begriffe unspezifiziertes Verhalten, undefiniertes Verhalten und implementierungsdefiniertes Verhalten werden verwendet, um das Ergebnis des Schreibens von Programmen zu kategorisieren, deren Eigenschaften der Standard nicht oder nicht vollständig beschreibt. Das Ziel dieser Kategorisierung ist es, eine bestimmte Vielfalt von Implementierungen zuzulassen, die es ermöglicht, dass die Qualität der Implementierung eine aktive Kraft auf dem Markt ist, sowie bestimmte populäre Erweiterungen zuzulassen , ohne das Gütesiegel der Konformität mit dem Standard zu entfernen. Anhang F des Standardkatalogs listet die Verhaltensweisen auf, 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 schwer zu diagnostizierende Programmfehler nicht abzufangen. Es werden auch Bereiche mit einer möglichen konformen Spracherweiterung 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 auf der Grundlage der Implementierungsdefinition sinnvolle Codierungsentscheidungen treffen kann. Implementierer sollten dieses Kriterium berücksichtigen, wenn sie entscheiden, wie umfangreich eine Implementierungsdefinition sein sollte. Wie bei nicht angegebenem Verhalten reicht es nicht aus, 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 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:

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 portablen Schreiben zwingen, um die Verwendung von C als "High-Level-Assembler" auszuschließen: die Fähigkeit, maschinenspezifisch zu schreiben Code ist eine der Stärken von C. Es ist dieses Prinzip, das die Unterscheidung zwischen einem streng konformen Programm und einem konformen Programm in hohem Maße motiviert (§ 1.7).

Und bei 1,7 merkt es

Die dreifache Definition der Konformität wird verwendet, um die Population der konformen Programme zu erweitern und zwischen konformen Programmen, die eine einzige Implementierung verwenden, und portablen konformen Programmen zu unterscheiden.

Ein streng konformes Programm ist ein weiterer Begriff für ein maximal portierbares Programm. Ziel ist es, dem Programmierer die Möglichkeit zu geben, leistungsstarke C-Programme zu entwickeln, die auch in hohem Maße portabel sind, ohne dabei nützliche C-Programme zu beeinträchtigen, die nicht portabel sind. Also das Adverb streng.

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


15

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.


+1 für den zweiten Absatz, der etwas zeigt, das als implementierungsdefiniertes Verhalten nur umständlich angegeben werden kann.
David Thornley

3
Die Bitverschiebung ist nur ein Beispiel für das Akzeptieren undefinierten Compilerverhaltens und die Verwendung der Hardwarefunktionen. Es wäre trivial, ein C-Ergebnis für eine Bitverschiebung anzugeben, wenn die Anzahl größer als der Typ ist, die Implementierung auf einer bestimmten Hardware jedoch teuer ist.
Mattnz

7

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.


3
Die Sprache C hätte so angegeben werden können, dass sie auf Systemen mit Ausrichtungseinschränkungen immer Byte-für-Byte-Lesevorgänge verwenden und Ausnahmefälle mit genau definiertem Verhalten für ungültige Adressenzugriffe bereitstellen musste. Aber natürlich wäre dies alles unglaublich kostspielig gewesen (in Bezug auf Codegröße, Komplexität und Leistung) und hätte keinerlei Vorteile für einen vernünftigen, korrekten Code gebracht.
R ..

6

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.


1
Huh? Was haben Ausnahmen mit eingebetteter Hardware zu tun?
Mason Wheeler

2
Ausnahmen können das System auf eine Weise blockieren, die für eingebettete Systeme, die schnell reagieren müssen, sehr schlecht ist. Es gibt Situationen, in denen ein falscher Messwert viel weniger schädlich ist als ein verlangsamtes System.
Welt Ingenieur

1
@Mason: Weil die Hardware den ungültigen Zugriff abfangen muss. Es ist für Windows einfach, eine Zugriffsverletzung zu verursachen, und für eingebettete Hardware ohne Betriebssystem ist es schwieriger, etwas anderes zu tun als zu sterben.
DeadMG

3
Denken Sie auch daran, dass nicht jede CPU über eine MMU verfügt, die vor ungültigen Hardwarezugriffen schützt. Wenn Sie von Ihrer Sprache verlangen, dass sie alle Zeigerzugriffe überprüft, müssen Sie eine MMU auf CPUs ohne eine emulieren - und somit wird JEDER Speicherzugriff extrem teuer.
flauschiger

4

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.


5
Ich vermute, Sie denken an Unix - C wurde ursprünglich auf dem PDP-11 verwendet, was eigentlich ziemlich konventionelle aktuelle Standards waren. Ich denke, die Grundidee steht trotzdem.
Jerry Coffin

@ Jerry - ja, du hast recht - ich werde alt!
Martin Beckett

Yup - passiert mit den Besten von uns, fürchte ich.
Jerry Coffin

4

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).


Ich frage mich, was die sich ändernde Philosophie von UB von "Programmierern erlauben, Verhaltensweisen zu verwenden, die von ihrer Plattform angezeigt werden" zu "Entschuldigungen finden, um Compilern zu erlauben, völlig verrücktes Verhalten zu implementieren" angetrieben hat. Ich frage mich auch, um wie viel solche Optimierungen die Codegröße verbessern, nachdem der Code so geändert wurde, dass er unter dem neuen Compiler funktioniert. Es würde mich nicht überraschen, wenn der Compiler in vielen Fällen nur durch das Hinzufügen solcher "Optimierungen" gezwungen würde, größeren und langsameren Code zu schreiben, damit der Compiler ihn nicht kaputt macht.
Supercat

Es ist ein Drift in POV. Die Leute wurden sich der Maschine, auf der ihr Programm ausgeführt wird, weniger bewusst, und sie kümmerten sich mehr um die Portabilität, sodass sie es vermieden, abhängig von undefiniertem, nicht spezifiziertem und implementierungsdefiniertem Verhalten zu werden. Optimierer wurden unter Druck gesetzt, um die besten Benchmark-Ergebnisse zu erzielen, und das bedeutet, dass sie jede Milde ausnutzen, die die Spezifikation der Sprachen mit sich bringt. Es gibt auch die Tatsache, dass Internet - Usenet zu einer Zeit, SE heutzutage - Sprachanwälte auch dazu neigen, eine voreingenommene Sicht der zugrundeliegenden Gründe und Verhaltensweisen von Compilerautoren zu geben.
AProgrammer

1
Was ich merkwürdig finde, sind Aussagen, die ich in Bezug auf "C geht davon aus, dass sich Programmierer niemals auf undefiniertes Verhalten einlassen" gesehen habe - eine Tatsache, die historisch nicht wahr war. Eine korrekte Aussage wäre gewesen: "C hat angenommen, dass Programmierer kein durch den Standard nicht definiertes Verhalten auslösen würden, wenn sie nicht bereit wären, sich mit den natürlichen Plattformfolgen dieses Verhaltens auseinanderzusetzen. Angesichts der Tatsache, dass C als Systemprogrammiersprache konzipiert wurde, war ein großer Teil seines Zwecks war es, Programmierern zu erlauben, systemspezifische Dinge zu tun, die nicht durch den Sprachstandard definiert sind, die Idee, dass sie dies niemals tun würden, ist absurd.
supercat

Es ist gut für Programmierer, zusätzliche Anstrengungen zu unternehmen, um die Portabilität in Fällen zu gewährleisten, in denen unterschiedliche Plattformen von Natur aus unterschiedliche Aufgaben ausführen. Compiler-Autoren verschwenden jedoch Zeit, wenn sie Verhaltensweisen beseitigen, von denen Programmierer in der Vergangenheit vernünftigerweise erwartet hatten, dass sie allen zukünftigen Compilern gemeinsam sind. Angesichts von ganzen Zahlen iund n, so dass n < INT_BITSund i*(1<<n)nicht überlaufen würde, würde ich es als i<<=n;klarer betrachten als i=(unsigned)i << n;; Auf vielen Plattformen wäre es schneller und kleiner als i*=(1<<N);. Was bringt es, wenn Compiler es verbieten?
Supercat

Ich denke, es wäre gut für den Standard, Traps für viele Dinge zuzulassen, die er als UB bezeichnet (z. B. Integer-Überlauf), und es gibt gute Gründe, nicht zu verlangen, dass Traps etwas Vorhersehbares tun, aber ich denke, dass dies von jedem Standpunkt aus vorstellbar ist Der Standard würde verbessert, wenn verlangt würde, dass die meisten Formen von UB entweder einen unbestimmten Wert erbringen oder die Tatsache dokumentieren müssen, dass sie sich das Recht vorbehalten, etwas anderes zu tun, ohne unbedingt zu dokumentieren, was dieses etwas anderes sein könnte. Compiler, die alles zu "UB" machten, wären legal, aber wahrscheinlich ungünstig ...
supercat

3

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.


Ganzzahladdition ist ein interessanter Fall; Abgesehen von der Möglichkeit eines Trap-Verhaltens, das in einigen Fällen nützlich wäre, in anderen Fällen jedoch eine zufällige Codeausführung verursachen könnte, gibt es Situationen, in denen es für einen Compiler vernünftig wäre, Schlussfolgerungen zu ziehen, die auf der Tatsache beruhen, dass kein Ganzzahlüberlauf zum Umbrechen angegeben ist. Beispielsweise könnte ein Compiler, bei dem int16 Bit und vorzeichenerweiterte Verschiebungen teuer sind, unter (uchar1*uchar2) >> 4Verwendung einer nicht vorzeichenerweiterten Verschiebung rechnen . Leider erweitern einige Compiler die Schlussfolgerungen nicht nur auf Ergebnisse, sondern auch auf Operanden.
Supercat

2

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.


Ursprünglich waren viele Verhaltensweisen nicht definiert, um die Möglichkeit zu berücksichtigen, dass unterschiedliche Systeme unterschiedliche Aufgaben ausführen, einschließlich des Auslösens eines Hardware-Traps mit einem Handler, der möglicherweise konfigurierbar ist oder nicht (und, falls er nicht konfiguriert ist, ein willkürlich unvorhersehbares Verhalten verursacht). Das Erfordernis, dass eine Linksverschiebung eines negativen Werts, der nicht abfängt, beispielsweise jeden Code unterbricht, der für ein System entwickelt wurde, in dem dies der Fall war, und sich auf ein solches Verhalten stützte. Kurz gesagt, sie wurden undefiniert gelassen , um Implementierer nicht daran zu hindern, Verhaltensweisen bereitzustellen, die sie für nützlich hielten .
Supercat

Leider wurde dies jedoch so verdreht, dass selbst Code, der weiß, dass er auf einem Prozessor ausgeführt wird, der in einem bestimmten Fall etwas Nützliches bewirken würde, ein solches Verhalten nicht ausnutzen kann, da Compiler möglicherweise die Tatsache verwenden, dass der C-Standard dies nicht tut Das Verhalten (obwohl die Plattform dies tun würde), um bizarro-world-Neuschreibungen auf den Code anzuwenden, wird nicht angegeben.
Supercat

1

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.


oder Sie könnten alle Zeiger als zuweisen weak_ptrund alle Verweise auf einen Zeiger aufheben, der deleted ... Oh, Moment, wir nähern uns der Garbage Collection: /
Matthieu M.

boost::weak_ptrDie Implementierung von ist eine ziemlich gute Vorlage für dieses Verwendungsmuster. Anstatt weak_ptrsextern zu verfolgen und aufzuheben , trägt a weak_ptrnur zur shared_ptrschwachen 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_ptrohne es sofort löschen zu müssen. Es ist nicht perfekt (Sie können immer noch eine Menge abgelaufener weak_ptrAktien haben, die den Basiswert shared_countohne triftigen Grund beibehalten ), aber es ist zumindest schnell und effizient.
flauschige

0

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.


Es gibt einen semantischen Unterschied zwischen der Aussage "Ein Compiler darf sich so verhalten, als ob X wahr wäre" und der Aussage "Jedes Programm, in dem X nicht wahr ist, führt zu Undefiniertem Verhalten", obwohl die Standards die Unterscheidung leider nicht klar machen. In vielen Situationen, einschließlich Ihres Aliasing-Beispiels, würde die vorherige Anweisung viele Compiler-Optimierungen ermöglichen, die sonst unmöglich wären. Letzteres erlaubt einige weitere "Optimierungen", aber viele der letzteren Optimierungen sind Dinge, die Programmierer nicht wollen würden.
Supercat

Wenn zum Beispiel ein Code a fooauf 42 setzt und dann eine Methode aufruft, die einen unrechtmäßig geänderten Zeiger zum Setzen fooauf 44 verwendet, kann ich den Vorteil sehen, zu sagen, dass der fooVersuch, ihn zu lesen , bis zum nächsten "legitimen" Schreiben legitim sein kann ergeben 42 oder 44, und ein Ausdruck wie foo+fookö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.
Supercat

0

In der Vergangenheit hatte undefiniertes Verhalten zwei Hauptziele:

  1. Um zu vermeiden, dass Compiler-Autoren Code generieren müssen, um Bedingungen zu handhaben, die niemals auftreten sollten.

  2. 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 intgegebene Code also wie folgt:

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

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 intundefiniertes Verhalten zu passen. Infolgedessen wäre der Compiler berechtigt, dies anzunehmen kann nicht passieren und müsste daher nicht erhöht werden, *pwenn dies der Fall ist. Wenn der aufrufende Code *pals 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.


-6

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.


7
Das OP spezifizierte dies: "Meine Frage bezieht sich nicht darauf, was undefiniertes Verhalten ist oder was wirklich schlecht ist. Ich kenne die Gefahren und die meisten relevanten undefinierten Verhaltenszitate aus dem Standard. Bitte veröffentlichen Sie keine Antworten darauf, wie schlecht es ist . " Sieht so aus, als hättest du die Frage nicht gelesen.
Etienne de Martel
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.