In diesem Beitrag zum Stapelüberlauf wird eine ziemlich umfassende Liste von Situationen aufgeführt, in denen die C / C ++ - Sprachspezifikation als "undefiniertes Verhalten" deklariert wird. Ich möchte jedoch verstehen, warum andere moderne Sprachen wie C # oder Java nicht das Konzept von "undefiniertem Verhalten" haben. Bedeutet dies, dass der Compiler-Designer alle möglichen Szenarien (C # und Java) steuern kann oder nicht (C und C ++)?
50
nullptr
) nicht man hat sich die Mühe gemacht, das Verhalten durch Schreiben und / oder Verabschieden einer vorgeschlagenen Spezifikation zu definieren. " : cAntworten:
Undefiniertes Verhalten ist eines jener Dinge, die nur im Nachhinein als sehr schlechte Idee erkannt wurden.
Die ersten Compiler waren großartige Erfolge und begrüßten erfreulicherweise Verbesserungen gegenüber der Alternative - Maschinensprache oder Assemblersprachenprogrammierung. Die damit verbundenen Probleme waren bekannt, und Hochsprachen wurden speziell erfunden, um diese bekannten Probleme zu lösen. (Die Begeisterung zu dieser Zeit war so groß, dass HLLs manchmal als "das Ende der Programmierung" bezeichnet wurden - als müssten wir von nun an nur noch trivial aufschreiben, was wir wollten, und der Compiler würde die ganze echte Arbeit erledigen.)
Erst später erkannten wir die neueren Probleme, die mit dem neueren Ansatz einhergingen. Wenn Sie sich nicht auf dem Computer befinden, auf dem der Code ausgeführt wird, besteht die Möglichkeit, dass Dinge stillschweigend nicht das tun, was wir von ihnen erwartet haben. Zum Beispiel würde das Zuweisen einer Variablen typischerweise den Anfangswert undefiniert lassen; Dies wurde nicht als Problem angesehen, da Sie keine Variable zuweisen würden, wenn Sie keinen Wert darin speichern möchten, oder? Sicherlich war es nicht zu viel zu erwarten, dass professionelle Programmierer nicht vergessen würden, den Anfangswert zuzuweisen, oder?
Es stellte sich heraus, dass mit den größeren Codebasen und komplizierteren Strukturen, die mit leistungsfähigeren Programmiersystemen möglich wurden, viele Programmierer tatsächlich von Zeit zu Zeit solche Versehen begehen und das resultierende undefinierte Verhalten zu einem Hauptproblem wurde. Sogar heute ist die Mehrheit der Sicherheitslücken von winzig bis schrecklich das Ergebnis von undefiniertem Verhalten in der einen oder anderen Form. (Der Grund dafür ist, dass undefiniertes Verhalten in der Regel sehr stark von Dingen auf der nächstniedrigeren Computerebene bestimmt wird. Angreifer, die diese Ebene verstehen, können diesen Spielraum nutzen, um ein Programm dazu zu bringen, nicht nur unbeabsichtigte Dinge, sondern genau die Dinge zu tun sie beabsichtigen.)
Seitdem wir dies erkannt haben, gab es einen allgemeinen Drang, undefiniertes Verhalten aus Hochsprachen zu verbannen, und Java war besonders gründlich dabei (was vergleichsweise einfach war, da es ohnehin für die Ausführung auf einer eigens entwickelten virtuellen Maschine ausgelegt war). Ältere Sprachen wie C können nicht einfach so nachgerüstet werden, ohne die Kompatibilität mit der riesigen Menge an vorhandenem Code zu verlieren.
Bearbeiten: Wie bereits erwähnt, ist Effizienz ein weiterer Grund. Undefiniertes Verhalten bedeutet, dass Compiler-Autoren viel Spielraum haben, um die Zielarchitektur auszunutzen, sodass jede Implementierung die schnellstmögliche Implementierung der einzelnen Features erreicht. Dies war bei Maschinen mit geringer Leistung von gestern wichtiger als heute, als das Gehalt der Programmierer häufig der Engpass bei der Softwareentwicklung ist.
quelle
int32_t add(int32_t x, int32_t y)
) in C ++ hinzufügt . Die üblichen Argumente dazu beziehen sich auf die Effizienz, sind jedoch oft mit einigen Argumenten für die Portabilität durchsetzt (wie in "Einmal schreiben, ausführen ... auf der Plattform, auf der Sie es geschrieben haben ... und nirgendwo anders ;-)"). Ein Argument könnte daher sein: Einige Dinge sind undefiniert, weil Sie nicht wissen, ob Sie sich auf einem 16-Bit-Mikrocontroller oder einem 64-Bit-Server befinden (ein schwaches, aber immer noch ein Argument)Grundsätzlich, weil die Designer von Java und ähnlichen Sprachen kein undefiniertes Verhalten in ihrer Sprache wollten. Dies war ein Kompromiss: Das Zulassen von undefiniertem Verhalten hat das Potenzial, die Leistung zu verbessern, aber die Sprachentwickler legten höheren Wert auf Sicherheit und Vorhersagbarkeit.
Wenn Sie beispielsweise ein Array in C zuweisen, sind die Daten undefiniert. In Java müssen alle Bytes mit 0 (oder einem anderen angegebenen Wert) initialisiert werden. Dies bedeutet, dass die Laufzeit über das Array laufen muss (eine O (n) -Operation), während C die Zuordnung sofort durchführen kann. Daher ist C für solche Operationen immer schneller.
Wenn der Code, der das Array verwendet, es trotzdem vor dem Lesen auffüllt, ist dies für Java im Grunde eine Verschwendung von Aufwand. In dem Fall, in dem der Code zuerst gelesen wird, erhalten Sie in Java vorhersehbare Ergebnisse, in C jedoch unvorhersehbare Ergebnisse.
quelle
valgrind
würde, der genau anzeigt, wo der nicht initialisierte Wert verwendet wurde. Sie könnenvalgrind
Java-Code nicht verwenden , da die Laufzeit die Initialisierungvalgrind
vornimmt und s-Prüfungen unbrauchbar machen.Undefiniertes Verhalten ermöglicht eine signifikante Optimierung, indem dem Compiler die Möglichkeit gegeben wird, an bestimmten Grenzen oder unter anderen Bedingungen etwas Ungewöhnliches oder Unerwartetes (oder sogar Normales) zu tun.
Siehe http://blog.llvm.org/2011/05/what-every-c-programmer-should-know.html
quelle
a + b
, kann eradd b a
in jeder Situation für den nativen Befehl kompiliert werden, anstatt dass ein Compiler möglicherweise eine andere Form der Ganzzahlarithmetik mit Vorzeichen simulieren muss.HashSet
. B. a zu verwenden, ist wunderbar.<<
könnte der schwierige Fall sein.x << y
Wertet auf einen gültigen Wert des Typs aus,int32_t
aber wir werden nicht sagen, welcher". Dies ermöglicht es den Implementierern, die schnelle Lösung zu verwenden, stellt jedoch keine falsche Voraussetzung für die Optimierung des Zeitreisestils dar, da der Nichtdeterminismus auf die Ausgabe dieser einen Operation beschränkt ist. Die Spezifikation garantiert, dass Speicher, flüchtige Variablen usw. nicht sichtbar beeinflusst werden durch die Ausdrucksbewertung. ...In den frühen Tagen von C herrschte viel Chaos. Verschiedene Compiler haben die Sprache unterschiedlich behandelt. Wenn es Interesse gab, eine Spezifikation für die Sprache zu schreiben, musste diese Spezifikation ziemlich abwärtskompatibel mit dem C sein, auf das sich Programmierer bei ihren Compilern stützten. Einige dieser Details sind jedoch nicht portierbar und im Allgemeinen nicht sinnvoll, beispielsweise wenn eine bestimmte Endianess oder ein bestimmtes Datenlayout vorausgesetzt wird. Der C-Standard behält sich daher viele Details als undefiniertes oder implementierungsspezifisches Verhalten vor, was den Compiler-Autoren viel Flexibilität lässt. C ++ baut auf C auf und bietet auch undefiniertes Verhalten.
Java versuchte eine viel sicherere und viel einfachere Sprache als C ++ zu sein. Java definiert die Sprachsemantik im Sinne einer vollständigen virtuellen Maschine. Dies lässt wenig Raum für undefiniertes Verhalten, stellt jedoch Anforderungen, die für eine Java-Implementierung schwierig sein können (z. B. dass Referenzzuweisungen atomar sein müssen oder wie Ganzzahlen funktionieren). Wenn Java potenziell unsichere Vorgänge unterstützt, werden diese normalerweise zur Laufzeit von der virtuellen Maschine überprüft (z. B. einige Casts).
quelle
this
überprüft eine Weile zurück, mit der Begründung , dass null?“this
Seinnullptr
UB ist, und somit kann eigentlich nie passieren.)JVM- und .NET-Sprachen haben es einfach:
Es gibt jedoch gute Punkte für die Auswahl:
Wenn Notluken vorgesehen sind, laden diese zu einem ausgereiften undefinierten Verhalten ein. Zumindest werden sie in der Regel nur in wenigen sehr kurzen Abschnitten verwendet, was die manuelle Überprüfung erleichtert.
quelle
unsafe
Stichwort oder Attribute inSystem.Runtime.InteropServices
). Indem wir diese Dinge den wenigen Programmierern überlassen, die wissen, wie man nicht verwaltete Dinge debuggt und wieder so wenig wie möglich davon, halten wir die Probleme niedrig. Es ist mehr als 10 Jahre her, dass es den letzten leistungsbezogenen Unsafe-Hammer gab, aber manchmal muss man es tun, weil es buchstäblich keine andere Lösung gibt.Java und C # zeichnen sich zumindest zu Beginn ihrer Entwicklung durch einen dominierenden Anbieter aus. (Sun bzw. Microsoft). C und C ++ sind unterschiedlich; Sie hatten von Anfang an mehrere konkurrierende Implementierungen. C lief vor allem auch auf exotischen Hardware-Plattformen. Infolgedessen gab es Unterschiede zwischen den Implementierungen. Die ISO-Komitees, die standardisiertes C und C ++ vereinbarten, konnten sich auf einen großen gemeinsamen Nenner einigen, aber an den Rändern, an denen Implementierungen voneinander abweichen, ließen die Standards Raum für die Implementierung.
Dies liegt auch daran, dass die Auswahl eines Verhaltens bei Hardwarearchitekturen, die auf eine andere Entscheidung abzielen, möglicherweise teuer ist - Endianness ist die naheliegende Wahl.
quelle
Der wahre Grund liegt in einem grundsätzlichen Unterschied in der Absicht zwischen C und C ++ einerseits und Java und C # (für nur einige Beispiele) andererseits. Aus historischen Gründen geht es in den meisten Diskussionen hier eher um C als um C ++, aber (wie Sie wahrscheinlich bereits wissen) ist C ++ ein ziemlich direkter Nachkomme von C, und das, was über C gesagt wird, gilt auch für C ++.
Obwohl sie größtenteils in Vergessenheit geraten sind (und ihre Existenz manchmal sogar geleugnet wird), wurden die allerersten Versionen von UNIX in Assemblersprache geschrieben. Ein Großteil (wenn nicht nur) des ursprünglichen Zwecks von C bestand darin, UNIX von der Assemblersprache auf eine höhere Sprache zu portieren. Teil der Absicht war es, so viel wie möglich des Betriebssystems in einer höheren Sprache zu schreiben - oder es aus der anderen Richtung zu betrachten, um die Menge zu minimieren, die in Assemblersprache geschrieben werden musste.
Um dies zu erreichen, musste C nahezu den gleichen Grad an Zugriff auf die Hardware bieten wie die Assemblersprache. Das PDP-11 (zum Beispiel) hat E / A-Register auf bestimmte Adressen abgebildet. Beispielsweise würden Sie einen Speicherort lesen, um zu überprüfen, ob eine Taste auf der Systemkonsole gedrückt wurde. Ein Bit wurde an dieser Stelle gesetzt, als Daten darauf warteten, gelesen zu werden. Sie haben dann ein Byte von einem anderen angegebenen Speicherort gelesen, um den ASCII-Code der gedrückten Taste abzurufen.
Wenn Sie einige Daten drucken möchten, überprüfen Sie einen anderen angegebenen Speicherort, und wenn das Ausgabegerät bereit ist, schreiben Sie Ihre Daten an einen anderen angegebenen Speicherort.
Um das Schreiben von Treibern für solche Geräte zu unterstützen, haben Sie in C die Möglichkeit, einen beliebigen Speicherort mit einem ganzzahligen Typ anzugeben, ihn in einen Zeiger zu konvertieren und diesen Speicherort im Speicher zu lesen oder zu schreiben.
Natürlich hat dies ein ziemlich ernstes Problem: Nicht jede Maschine auf der Erde verfügt über einen Speicher, der mit einem PDP-11 aus den frühen 1970er Jahren identisch ist. Wenn Sie also diese Ganzzahl nehmen, in einen Zeiger konvertieren und dann über diesen Zeiger lesen oder schreiben, kann niemand eine angemessene Garantie dafür geben, was Sie erhalten werden. Nur für ein naheliegendes Beispiel: Lesen und Schreiben werden möglicherweise separaten Registern in der Hardware zugeordnet. Wenn Sie also etwas schreiben (im Gegensatz zum normalen Speicher), versuchen Sie, es zurückzulesen. Das Gelesene stimmt möglicherweise nicht mit dem überein, was Sie geschrieben haben.
Ich sehe ein paar Möglichkeiten, die sich ergeben:
Von diesen scheint 1 so absurd, dass es kaum einer weiteren Diskussion wert ist. 2 wirft im Grunde die grundlegende Absicht der Sprache weg. Damit bleibt die dritte Option im Wesentlichen die einzige, die sie vernünftigerweise überhaupt in Betracht ziehen könnten.
Ein weiterer Punkt, der ziemlich häufig auftritt, ist die Größe von Ganzzahltypen. C nimmt die "Position" ein,
int
die der natürlichen Größe entsprechen soll, die von der Architektur vorgeschlagen wird. Wenn ich also ein 32-Bit-VAX programmiere,int
sollte es wahrscheinlich 32 Bit sein, aber wenn ich ein 36-Bit-Univac programmiere,int
sollte es wahrscheinlich 36 Bit sein (und so weiter). Es ist wahrscheinlich nicht sinnvoll (und möglicherweise auch nicht möglich), ein Betriebssystem für einen 36-Bit-Computer nur mit Typen zu schreiben, deren Größe garantiert ein Vielfaches von 8 Bit beträgt. Vielleicht bin ich nur oberflächlich, aber wenn ich ein Betriebssystem für eine 36-Bit-Maschine schreibe, möchte ich wahrscheinlich eine Sprache verwenden, die einen 36-Bit-Typ unterstützt.Aus sprachlicher Sicht führt dies zu noch undefiniertem Verhalten. Was passiert, wenn ich 1 addiere, wenn ich den größten Wert nehme, der in 32 Bit passt? Bei typischer 32-Bit-Hardware wird ein Rollover ausgeführt (oder möglicherweise ein Hardwarefehler). Auf der anderen Seite, wenn es auf 36-Bit-Hardware läuft, wird es nur ... eine hinzufügen. Wenn die Sprache das Schreiben von Betriebssystemen unterstützt, können Sie keines der beiden Verhalten garantieren - Sie müssen lediglich zulassen, dass sowohl die Größen der Typen als auch das Verhalten des Überlaufs von einem zum anderen variieren.
Java und C # können all das ignorieren. Sie unterstützen nicht das Schreiben von Betriebssystemen. Mit ihnen haben Sie eine Reihe von Möglichkeiten. Eine besteht darin, die Hardware so zu gestalten, wie sie es erfordert - da sie Typen mit 8, 16, 32 und 64 Bit erfordert, müssen Sie nur Hardware erstellen, die diese Größen unterstützt. Die andere naheliegende Möglichkeit besteht darin, dass die Sprache nur auf einer anderen Software ausgeführt wird, die die gewünschte Umgebung bietet, unabhängig davon, welche zugrunde liegende Hardware gewünscht wird.
In den meisten Fällen ist dies keine Entweder-Oder-Wahl. Vielmehr machen viele Implementierungen ein wenig von beidem. Normalerweise führen Sie Java auf einer JVM aus, die auf einem Betriebssystem ausgeführt wird. Meistens ist das Betriebssystem in C und die JVM in C ++ geschrieben. Wenn die JVM auf einer ARM-CPU ausgeführt wird, stehen die Chancen gut, dass die CPU die Jazelle-Erweiterungen von ARM enthält, um die Hardware besser an die Anforderungen von Java anzupassen, sodass weniger Software erforderlich ist und der Java-Code schneller (oder weniger) ausgeführt wird langsam sowieso).
Zusammenfassung
C und C ++ haben ein undefiniertes Verhalten, da niemand eine akzeptable Alternative definiert hat, die es ihnen ermöglicht, das zu tun, was sie beabsichtigt haben. C # und Java verfolgen einen anderen Ansatz, aber dieser Ansatz passt (wenn überhaupt) schlecht zu den Zielen von C und C ++. Insbesondere scheint keines der beiden Verfahren eine vernünftige Möglichkeit zu bieten, Systemsoftware (z. B. ein Betriebssystem) auf die am meisten willkürlich ausgewählte Hardware zu schreiben. Beides hängt in der Regel von Funktionen ab, die von vorhandener Systemsoftware (normalerweise in C oder C ++ geschrieben) bereitgestellt werden, um ihre Arbeit zu erledigen.
quelle
Die Autoren des C-Standards erwarteten von ihren Lesern, dass sie etwas erkannten, was sie für offensichtlich hielten und in ihrer veröffentlichten Begründung anspielten, sagten jedoch nicht direkt: Das Komitee sollte keine Compiler-Autoren bestellen müssen, um die Bedürfnisse ihrer Kunden zu erfüllen. da die Kunden besser als der Ausschuss wissen sollten, was ihre Bedürfnisse sind. Wenn es offensichtlich ist, dass Compiler für bestimmte Arten von Plattformen erwartet werden, dass sie ein Konstrukt auf eine bestimmte Weise verarbeiten, sollte es niemanden interessieren, ob der Standard besagt, dass das Konstrukt Undefiniertes Verhalten aufruft. Das Versäumnis des Standards, konforme Compiler zur sinnvollen Verarbeitung von Code zu verpflichten, impliziert in keiner Weise, dass Programmierer bereit sein sollten, Compiler zu kaufen, die dies nicht tun.
Dieser Ansatz für Sprachdesign funktioniert sehr gut in einer Welt, in der Compiler-Autoren ihre Waren an zahlende Kunden verkaufen müssen. Es zerfällt völlig in einer Welt, in der Compiler-Autoren von den Auswirkungen des Marktes isoliert sind. Es ist zweifelhaft, ob es jemals die richtigen Marktbedingungen geben wird, um eine Sprache so zu steuern, wie sie in den 90er Jahren populär wurde, und noch zweifelhafter, ob sich ein vernünftiger Sprachdesigner auf solche Marktbedingungen verlassen möchte.
quelle
C ++ und c haben beide beschreibende Standards (die ISO-Versionen jedenfalls).
Die nur existieren, um zu erklären, wie die Sprachen funktionieren, und um einen einzigen Verweis darüber zu geben, was die Sprache ist. In der Regel geben Compiler-Anbieter und Bibliotheksschreiber die Richtung vor und einige Vorschläge werden in den ISO-Hauptstandard aufgenommen.
Java und C # (oder Visual C #, von dem ich annehme, dass Sie es meinen) haben vorgeschriebene Standards. Sie sagen Ihnen, was in der Sprache definitiv vor der Zeit ist, wie es funktioniert und was als erlaubtes Verhalten gilt.
Wichtiger noch ist, dass Java tatsächlich eine "Referenzimplementierung" in Open-JDK hat. (Ich denke, Roslyn zählt als Visual C # -Referenzimplementierung, konnte aber keine Quelle dafür finden.)
In Javas Fall, wenn der Standard mehrdeutig ist und Open-JDK dies auf eine bestimmte Weise tut. Die Art und Weise, wie Open-JDK dies tut, ist der Standard.
quelle
Undefiniertes Verhalten ermöglicht es dem Compiler, auf einer Vielzahl von Architekturen sehr effizienten Code zu generieren. Eriks Antwort erwähnt die Optimierung, aber sie geht darüber hinaus.
Beispielsweise sind signierte Überläufe in C undefiniertes Verhalten. In der Praxis sollte der Compiler einen einfachen signierten Additions-Opcode für die CPU generieren, der ausgeführt werden sollte.
Dies ermöglichte es C, auf den meisten Architekturen eine sehr gute Leistung zu erbringen und sehr kompakten Code zu erzeugen. Wenn der Standard festgelegt hätte, dass vorzeichenbehaftete Ganzzahlen auf bestimmte Weise überlaufen müssen, hätten CPUs, die sich anders verhalten, viel mehr Code für eine einfache vorzeichenbehaftete Addition benötigt.
Das ist der Grund für einen Großteil des undefinierten Verhaltens in C und warum Dinge wie die Größe von
int
zwischen Systemen variieren.Int
ist architekturabhängig und wird im Allgemeinen als der schnellste und effizienteste Datentyp ausgewählt, der größer als a istchar
.Als C neu war, waren diese Überlegungen wichtig. Computer waren weniger leistungsfähig und verfügten oft über eine begrenzte Verarbeitungsgeschwindigkeit und Speicher. C wurde dort eingesetzt, wo es auf Leistung ankommt, und von den Entwicklern wurde erwartet, dass sie verstehen, wie Computer gut genug funktionieren, um zu wissen, wie sich diese undefinierten Verhaltensweisen auf ihren jeweiligen Systemen auswirken würden.
Spätere Sprachen wie Java und C # haben es vorgezogen, undefiniertes Verhalten gegenüber unformatierter Leistung zu eliminieren.
quelle
In gewissem Sinne hat Java es auch. Angenommen, Sie haben Arrays.sort einen falschen Komparator zugewiesen. Es kann eine Ausnahme auslösen, wenn es es erkennt. Andernfalls wird ein Array auf eine Weise sortiert, von der nicht garantiert wird, dass sie eine bestimmte ist.
Wenn Sie eine Variable aus mehreren Threads ändern, sind die Ergebnisse ebenfalls nicht vorhersehbar.
C ++ ist nur noch einen Schritt weiter gegangen, um mehr Situationen undefiniert zu machen (oder besser gesagt, Java hat beschlossen, mehr Operationen zu definieren) und einen Namen dafür zu haben.
quelle
a
wäre undefiniertes Verhalten, wenn Sie 51 oder 73 davon erhalten könnten, aber wenn Sie nur 53 oder 71 erhalten können, ist es gut definiert.