it-swarm-eu.dev

Warum hat C ++ 'undefined behaviour' (UB) und andere Sprachen wie C # oder Java nicht?)?

Dieser Beitrag zum Stapelüberlauf listet eine ziemlich umfassende Liste von Situationen auf, in denen die C/C++ - Sprachspezifikation als "undefiniertes Verhalten" deklariert. Ich möchte jedoch verstehen, warum andere moderne Sprachen wie C # oder Java nicht das Konzept eines "undefinierten Verhaltens" haben. Bedeutet dies, dass der Compiler-Designer alle möglichen Szenarien (C # und Java) steuern kann oder nicht (C und C++)?

52
Sisir

ndefiniertes Verhalten ist eines der Dinge, die nur im Nachhinein als sehr schlechte Idee erkannt wurden.

Die ersten Compiler waren großartige Erfolge und begrüßten jubelnd Verbesserungen gegenüber der Alternative - Maschinensprache oder Assembler-Programmierung. Die Probleme damit waren bekannt, und Hochsprachen wurden speziell erfunden, um diese bekannten Probleme zu lösen. (Die damalige Begeisterung war so groß, dass HLLs manchmal als "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 eigentliche Arbeit erledigen.)

Erst später erkannten wir die neueren Probleme, die mit dem neueren Ansatz einhergingen. Wenn Sie von der tatsächlichen Maschine entfernt sind, auf der 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 normalerweise den Anfangswert undefiniert lassen; Dies wurde nicht als Problem angesehen, da Sie keine Variable zuweisen würden, wenn Sie keinen Wert darin halten 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, tatsächlich viele Programmierer von Zeit zu Zeit solche Versehen begangen haben und das daraus resultierende undefinierte Verhalten zu einem Hauptproblem wurde. Noch heute ist die Mehrzahl der Sicherheitslücken von winzig bis schrecklich auf undefiniertes Verhalten in der einen oder anderen Form zurückzuführen. (Der Grund dafür ist, dass undefiniertes Verhalten normalerweise sehr stark von Dingen auf der nächstniedrigeren Ebene des Rechnens definiert wird und Angreifer, die diese Ebene verstehen, diesen Spielraum nutzen können, um ein Programm nicht nur unbeabsichtigte Dinge, sondern genau die Dinge tun zu lassen sie beabsichtigen.)

Seit wir dies erkannt haben, gab es einen allgemeinen Drang, undefiniertes Verhalten aus Hochsprachen zu verbannen, und Java war diesbezüglich besonders gründlich (was vergleichsweise einfach war, da es so konzipiert war, dass es auf seinen Sprachen ausgeführt werden kann) ohnehin eine speziell entwickelte virtuelle Maschine). Ältere Sprachen wie C können nicht einfach so nachgerüstet werden, ohne die Kompatibilität mit der großen Menge an vorhandenem Code zu verlieren.

Edit : Wie bereits erwähnt, ist Effizienz ein weiterer Grund. Undefiniertes Verhalten bedeutet, dass Compiler-Writer viel Spielraum für die Ausnutzung der Zielarchitektur haben, sodass jede Implementierung mit der schnellstmöglichen Implementierung jeder Funktion davonkommt. Dies war bei Maschinen mit geringer Leistung von gestern wichtiger als heute, als das Gehalt für Programmierer häufig der Engpass bei der Softwareentwicklung ist.

72
Kilian Foth

Grundsätzlich, weil die Designer von Java und ähnliche 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 Sprachdesigner haben der Sicherheit Priorität eingeräumt und Vorhersagbarkeit höher.

Wenn Sie beispielsweise ein Array in C zuweisen, sind die Daten undefiniert. In Java müssen alle Bytes auf 0 (oder einen anderen angegebenen Wert) initialisiert werden. Dies bedeutet, dass die Laufzeit über das Array laufen muss (eine Operation O(n))), während C die Zuordnung sofort ausfü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 genommen eine Verschwendung von Aufwand. In dem Fall, in dem der Code zuerst gelesen wird, erhalten Sie vorhersagbare Ergebnisse in Java, aber unvorhersehbare Ergebnisse in C.

103
JacquesB

Undefiniertes Verhalten ermöglicht eine signifikante Optimierung, indem dem Compiler die Möglichkeit gegeben wird, an bestimmten Randbedingungen oder unter anderen Bedingungen etwas Seltsames oder Unerwartetes (oder sogar Normales) zu tun.

Siehe http://blog.llvm.org/2011/05/what-every-c-programmer-should-know.html

Verwendung einer nicht initialisierten Variablen: Dies ist allgemein als Ursache für Probleme in C-Programmen bekannt, und es gibt viele Tools, um diese zu erfassen: von Compiler-Warnungen bis hin zu statischen und dynamischen Analysatoren. Dies verbessert die Leistung, da nicht alle Variablen beim Initialisieren auf Null initialisiert werden müssen (wie Java)). Bei den meisten skalaren Variablen würde dies wenig Overhead verursachen, aber Stack-Arrays und Malloc'd Speicher würde ein Memset des Speichers verursachen, was ziemlich kostspielig sein könnte, insbesondere da der Speicher normalerweise vollständig überschrieben wird.


Überlauf mit vorzeichenbehafteten Ganzzahlen: Wenn die Arithmetik für einen Int-Typ (z. B.) überläuft, ist das Ergebnis undefiniert. Ein Beispiel ist, dass "INT_MAX + 1" nicht garantiert INT_MIN ist. Dieses Verhalten ermöglicht bestimmte Optimierungsklassen, die für einen bestimmten Code wichtig sind. Wenn Sie beispielsweise wissen, dass INT_MAX + 1 undefiniert ist, können Sie "X + 1> X" auf "true" optimieren. Wenn Sie wissen, dass die Multiplikation nicht überlaufen kann (weil dies undefiniert wäre), können Sie "X * 2/2" auf "X" optimieren. Während diese trivial erscheinen mögen, werden diese Dinge häufig durch Inlining und Makroerweiterung aufgedeckt. Eine wichtigere Optimierung, die dies ermöglicht, ist für "<=" Schleifen wie diese:

for (i = 0; i <= N; ++i) { ... }

In dieser Schleife kann der Compiler davon ausgehen, dass die Schleife genau N + 1 Mal iteriert, wenn "i" beim Überlauf undefiniert ist, wodurch eine breite Palette von Schleifenoptimierungen aktiviert werden kann Wenn die Variable so definiert ist, dass sie sich beim Überlauf umschließt, muss der Compiler davon ausgehen, dass die Schleife möglicherweise unendlich ist (was passiert, wenn N INT_MAX ist). Dadurch werden diese wichtigen Schleifenoptimierungen deaktiviert. Dies betrifft insbesondere 64-Bit-Plattformen, da so viel Code "int" als Induktionsvariablen verwendet.

42
Erik Eidt

In den frühen Tagen von C herrschte viel Chaos. Verschiedene Compiler behandelten die Sprache unterschiedlich. Wenn Interesse bestand, eine Spezifikation für die Sprache zu schreiben, musste diese Spezifikation ziemlich abwärtskompatibel mit dem C sein, auf das sich Programmierer mit ihren Compilern verlassen. Einige dieser Details sind jedoch nicht portierbar und im Allgemeinen nicht sinnvoll, beispielsweise unter der Annahme einer bestimmten Endianess oder eines bestimmten Datenlayouts. Der C-Standard behält sich daher viele Details als undefiniertes oder implementierungsspezifisches Verhalten vor, was Compiler-Autoren viel Flexibilität lässt. C++ baut auf C auf und bietet auch undefiniertes Verhalten.

Java hat versucht, eine viel sicherere und einfachere Sprache als C++ zu sein. Java definiert die Sprachsemantik in Bezug auf eine gründliche virtuelle Maschine. Dies lässt wenig Raum für undefiniertes Verhalten, andererseits stellt es 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).

20
amon

JVM- und .NET-Sprachen haben es einfach:

  1. Sie müssen nicht direkt mit Hardware arbeiten können.
  2. Sie müssen nur mit modernen Desktop- und Serversystemen oder einigermaßen ähnlichen Geräten oder zumindest mit für sie entwickelten Geräten arbeiten.
  3. Sie können eine Speicherbereinigung für den gesamten Speicher und eine erzwungene Initialisierung festlegen, um die Zeigersicherheit zu gewährleisten.
  4. Sie wurden von einem einzelnen Akteur festgelegt, der auch die endgültige Implementierung lieferte.
  5. Sie können Sicherheit vor Leistung wählen.

Es gibt jedoch gute Punkte für die Auswahl:

  1. Die Systemprogrammierung ist ein ganz anderes Ballspiel, und eine kompromisslose Optimierung für die Anwendungsprogrammierung ist sinnvoll.
  2. Zugegeben, es gibt immer weniger exotische Hardware, aber kleine eingebettete Systeme bleiben erhalten.
  3. GC ist für nicht fungible Ressourcen schlecht geeignet und bietet viel mehr Platz für eine gute Leistung. Und die meisten (aber nicht fast alle) erzwungenen Initialisierungen können entfernt werden.
  4. Mehr Wettbewerb hat Vorteile, aber Ausschüsse bedeuten Kompromisse.
  5. Alle diese Grenzprüfungen do summieren sich, obwohl die meisten wegoptimiert werden können. Nullzeigerprüfungen können meistens durchgeführt werden, indem der Zugriff dank des virtuellen Adressraums auf Null Overhead begrenzt wird, obwohl die Optimierung immer noch verhindert ist.

Wenn Notluken vorgesehen sind, laden diese zu einem vollständigen, undefinierten Verhalten ein. Zumindest werden sie jedoch im Allgemeinen nur in wenigen sehr kurzen Abschnitten verwendet, die daher einfacher manuell zu überprüfen sind.

14
Deduplicator

Java und C # zeichnen sich zumindest zu Beginn ihrer Entwicklung durch einen dominanten Anbieter aus. (Sun bzw. Microsoft). C und C++ sind unterschiedlich; Sie hatten von Anfang an mehrere konkurrierende Implementierungen. C lief auch besonders auf exotischen Hardwareplattformen. Infolgedessen gab es Unterschiede zwischen den Implementierungen. Die ISO-Komitees, die C und C++ standardisierten, konnten sich auf einen großen gemeinsamen Nenner einigen, aber an den Rändern, an denen sich die Implementierungen unterscheiden, ließen die Standards Raum für die Implementierung.

Dies liegt auch daran, dass die Auswahl eines Verhaltens bei Hardware-Architekturen, die auf eine andere Wahl ausgerichtet sind, teuer sein kann - Endianness ist die offensichtliche Wahl.

8
MSalters

Der wahre Grund liegt in einem grundlegenden Unterschied in der Absicht zwischen C und C++ einerseits und Java und C # (nur für einige Beispiele) andererseits. Aus historischen Gründen spricht ein Großteil der Diskussion hier eher von C als von C++, aber (wie Sie wahrscheinlich bereits wissen) ist C++ ein ziemlich direkter Nachkomme von C, sodass das, was es über C sagt, auch für C++ gilt.

Obwohl sie weitgehend vergessen sind (und ihre Existenz manchmal sogar geleugnet wird), wurden die allerersten Versionen von UNIX in Assemblersprache geschrieben. Ein Großteil (wenn nicht ausschließlich) 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 vom Betriebssystem 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 dieselbe Zugriffsebene auf die Hardware bereitstellen wie die Assemblersprache. Der PDP-11 (zum Beispiel) hat E/A-Register bestimmten Adressen zugeordnet. Sie würden beispielsweise einen Speicherort lesen, um zu überprüfen, ob auf der Systemkonsole eine Taste 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 Code ASCII der Taste abzurufen, die gedrückt wurde.

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, konnten Sie mit C einen beliebigen Speicherort mit einem ganzzahligen Typ angeben, ihn in einen Zeiger konvertieren und diesen Speicherort im Speicher lesen oder schreiben.

Dies hat natürlich ein ziemlich ernstes Problem: Nicht jeder Computer auf der Erde hat seinen Speicher identisch mit einem PDP-11 aus den frühen 1970er Jahren. Wenn Sie also diese Ganzzahl nehmen, sie in einen Zeiger konvertieren und dann über diesen Zeiger lesen oder schreiben, kann niemand eine vernünftige Garantie dafür geben, was Sie erhalten werden. Nur für ein offensichtliches Beispiel kann das Lesen und Schreiben getrennten Registern in der Hardware zugeordnet werden. Wenn Sie also (im Gegensatz zum normalen Speicher) etwas schreiben, versuchen Sie, es zurückzulesen. Was Sie lesen, stimmt möglicherweise nicht mit dem überein, was Sie geschrieben haben.

Ich sehe ein paar Möglichkeiten, die sich ergeben:

  1. Definieren Sie eine Schnittstelle zu allen möglichen Hardwarekomponenten. Geben Sie die absoluten Adressen aller Speicherorte an, die Sie möglicherweise lesen oder schreiben möchten, um auf irgendeine Weise mit der Hardware zu interagieren.
  2. Verbieten Sie diese Zugriffsebene und schreiben Sie vor, dass jeder, der solche Dinge tun möchte, die Assemblersprache verwenden muss.
  3. Erlauben Sie den Leuten, dies zu tun, aber überlassen Sie es ihnen, (zum Beispiel) die Handbücher für die Hardware zu lesen, auf die sie abzielen, und den Code so zu schreiben, dass er zu der von ihnen verwendeten Hardware passt.

Von diesen scheint 1 so absurd, dass es kaum eine weitere Diskussion wert ist. 2 wirft im Grunde die grundlegende Absicht der Sprache weg. Damit bleibt die dritte Option im Wesentlichen die einzige, die sie überhaupt in Betracht ziehen könnten.

Ein weiterer Punkt, der ziemlich häufig auftaucht, ist die Größe von Ganzzahltypen. C nimmt die "Position" ein, dass int die von der Architektur vorgeschlagene natürliche Größe sein sollte. Wenn ich also eine 32-Bit-VAX programmiere, sollte int wahrscheinlich 32 Bit sein, aber wenn ich eine 36-Bit-Univac programmiere, sollte int wahrscheinlich 36 Bit (und) sein bald). Es ist wahrscheinlich nicht sinnvoll (und möglicherweise sogar nicht möglich), ein Betriebssystem für einen 36-Bit-Computer nur mit Typen zu schreiben, die garantiert ein Vielfaches von 8 Bit haben. Vielleicht bin ich nur oberflächlich, aber es scheint mir, dass ich, wenn ich ein Betriebssystem für einen 36-Bit-Computer schreibe, wahrscheinlich eine Sprache verwenden möchte, die einen 36-Bit-Typ unterstützt.

Aus sprachlicher Sicht führt dies zu einem noch undefinierteren Verhalten. Was passiert, wenn ich den größten Wert nehme, der in 32 Bit passt, wenn ich 1 addiere? Bei typischer 32-Bit-Hardware wird ein Rollover durchgeführt (oder möglicherweise ein Hardwarefehler ausgelöst). 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 Verhaltensweisen garantieren. Sie müssen lediglich zulassen, dass sowohl die Größe der Typen als auch das Verhalten des Überlaufs von einem zum anderen variieren.

Java und C # können all das ignorieren. Sie sollen das Schreiben von Betriebssystemen nicht unterstützen. Mit ihnen haben Sie ein paar Möglichkeiten. Eine besteht darin, die Hardware so zu unterstützen, wie sie benötigt wird. Da sie Typen mit 8, 16, 32 und 64 Bit benötigen, erstellen Sie einfach Hardware, die diese Größen unterstützt. Die andere offensichtliche Möglichkeit besteht darin, dass die Sprache nur auf anderer Software ausgeführt wird, die die gewünschte Umgebung bietet, unabhängig davon, was die zugrunde liegende Hardware wünscht.

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 Java-Anforderungen anzupassen, sodass weniger Software und die _ erforderlich sindJava Code läuft schneller (oder jedenfalls weniger langsam).

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 tun sollen. C # und Java verfolgen einen anderen Ansatz, aber dieser Ansatz passt (wenn überhaupt) schlecht zu den Zielen von C und C++. Insbesondere scheint beides keine vernünftige Möglichkeit zu bieten, Systemsoftware (wie ein Betriebssystem) auf die am meisten willkürlich ausgewählte Hardware zu schreiben. Beide hängen in der Regel von Einrichtungen ab, die von vorhandener Systemsoftware (normalerweise in C oder C++ geschrieben) bereitgestellt werden, um ihre Aufgaben zu erledigen.

6
Jerry Coffin

Die Autoren des C-Standards erwarteten von ihren Lesern, dass sie etwas erkennen, das sie für offensichtlich hielten, und spielten in ihrer veröffentlichten Begründung darauf an, 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 von Compilern für bestimmte Arten von Plattformen erwartet wird, 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 dazu zu verpflichten, einen Code in keiner Weise sinnvoll zu verarbeiten, impliziert, dass Programmierer bereit sein sollten, Compiler zu kaufen, die dies nicht tun.

Dieser Ansatz für das Sprachdesign funktioniert sehr gut in einer Welt, in der Compiler-Autoren ihre Waren an zahlende Kunden verkaufen müssen. In einer Welt, in der Compiler-Autoren von den Auswirkungen des Marktes isoliert sind, fällt es völlig auseinander. Es ist zweifelhaft, ob es jemals die richtigen Marktbedingungen geben wird, um eine Sprache so zu steuern, wie sie die in den neunziger Jahren populäre Sprache gesteuert hat, und noch zweifelhafter, dass sich jeder vernünftige Sprachdesigner auf solche Marktbedingungen verlassen möchte.

4
supercat

C++ und c haben beide beschreibende Standards (die ISO-Versionen jedenfalls).

Die nur existieren, um zu erklären, wie die Sprachen funktionieren, und um eine einzige Referenz darüber zu liefern, was die Sprache ist. In der Regel sind Compiler-Anbieter und Bibliotheksschreiber führend, und einige Vorschläge werden in den ISO-Hauptstandard aufgenommen.

Java und C # (oder Visual C #, von denen ich annehme, dass Sie es meinen) haben vorgeschriebene Standards. Sie sagen Ihnen, was in der Sprache definitiv im Voraus ist, wie es funktioniert und was als zulässiges Verhalten angesehen wird.

Wichtiger als das, Java hat tatsächlich eine "Referenzimplementierung" in Open-JDK. (Ich denke Roslyn zählt als Visual C # -Referenzimplementierung, konnte es aber nicht finde eine Quelle dafür.)

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.

3
bobsburner

Durch undefiniertes Verhalten kann der Compiler auf einer Vielzahl von Architekturen sehr effizienten Code generieren. Eriks Antwort erwähnt die Optimierung, aber sie geht darüber hinaus.

Beispielsweise sind vorzeichenbehaftete Überläufe in C ein undefiniertes Verhalten. In der Praxis wurde vom Compiler erwartet, dass er einen einfachen vorzeichenbehafteten Additions-Opcode generiert, den die CPU ausführen kann, und das Verhalten ist das, was diese bestimmte CPU getan hat.

Dadurch konnte C auf den meisten Architekturen eine sehr gute Leistung erbringen und sehr kompakten Code erzeugen. Wenn der Standard angegeben hätte, dass vorzeichenbehaftete Ganzzahlen auf eine bestimmte Weise überlaufen müssen, hätten CPUs, die sich anders verhalten, viel mehr Code für eine einfache vorzeichenbehaftete Addition benötigt.

Dies ist der Grund für einen Großteil des undefinierten Verhaltens in C und warum Dinge wie die Größe von int zwischen den Systemen variieren. Int ist architekturabhängig und wird im Allgemeinen als der schnellste und effizienteste Datentyp ausgewählt, der größer als ein char ist.

Als C neu war, waren diese Überlegungen wichtig. Computer waren weniger leistungsfähig und hatten oft eine begrenzte Verarbeitungsgeschwindigkeit und einen begrenzten Speicher. C wurde dort eingesetzt, wo die Leistung wirklich wichtig war, und von den Entwicklern wurde erwartet, dass sie verstehen, wie Computer gut genug funktionieren, um zu wissen, wie diese undefinierten Verhaltensweisen auf ihren jeweiligen Systemen tatsächlich aussehen würden.

Spätere Sprachen wie Java und C # zogen es vor, undefiniertes Verhalten gegenüber der Rohleistung zu eliminieren.

1
user