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


50

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 ++)?




3
und doch bezieht sich dieser SO- Beitrag auch in der Java-Spezifikation auf undefiniertes Verhalten!
gbjbaanb

„Warum C ++ hat‚nicht definiertes Verhalten‘“ Leider scheint dies eine der Fragen zu sein , die schwierig sind , objektiv zu beantworten, über die Aussage „ , weil aus Gründen , X, Y und / oder Z (alle davon sein kann nullptr) nicht man hat sich die Mühe gemacht, das Verhalten durch Schreiben und / oder Verabschieden einer vorgeschlagenen Spezifikation zu definieren. " : c
code_dredd

Ich würde die Prämisse in Frage stellen. Mindestens C # hat "unsicheren" Code. Microsoft schreibt: "In gewissem Sinne ist das Schreiben von unsicherem Code mit dem Schreiben von C-Code in einem C # -Programm vergleichbar." Dafür wurde C erfunden (verdammt, sie haben das Betriebssystem in C geschrieben!), Also haben Sie es da.
Peter - Wiedereinsetzung von Monica

Antworten:


72

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.


56
Ich glaube nicht, dass viele Leute aus der C-Community dieser Aussage zustimmen würden. Wenn Sie C nachrüsten und undefiniertes Verhalten definieren würden (z. B. alles standardmäßig initialisieren, eine Auswertungsreihenfolge für Funktionsparameter auswählen usw.), würde die große Basis an gut verhaltenem Code weiterhin einwandfrei funktionieren. Nur Code, der heute nicht genau definiert ist, würde gestört. Auf der anderen Seite, wenn Sie wie heute undefiniert lassen, können Compiler weiterhin neue Fortschritte in der CPU-Architektur und der Code-Optimierung nutzen.
Christophe

13
Der Hauptteil der Antwort klingt für mich nicht wirklich überzeugend. Ich meine, es ist im Grunde unmöglich, eine Funktion zu schreiben, die sicher zwei Zahlen (wie in 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)
Marco13

12
@ Marco13 Einverstanden - und das Thema "undefiniertes Verhalten" loswerden, indem man etwas "definiertes Verhalten" macht, aber nicht unbedingt das, was der Benutzer wollte, und ohne Vorwarnung, wenn es passiert "anstatt" undefiniertes Verhalten "spielt nur Code-Lawyer-Spiele IMO .
alephzero

9
"Noch heute ist die Mehrheit der Sicherheitslücken von winzig bis schrecklich das Ergebnis von undefiniertem Verhalten in der einen oder anderen Form." Zitat benötigt. Ich dachte, die meisten von ihnen wären jetzt XYZ-Injektionen.
Joshua

34
"Undefiniertes Verhalten ist eines jener Dinge, die nur im Nachhinein als sehr schlechte Idee erkannt wurden." Das ist deine Meinung. Viele (ich eingeschlossen) teilen es nicht.
Leichtigkeit Rennen mit Monica

103

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.


19
Hervorragende Darstellung des HLL-Dilemmas: Sicherheit und Benutzerfreundlichkeit im Vergleich zur Leistung. Es gibt keine Silberkugel: Es gibt Anwendungsfälle für jede Seite.
Christophe

5
@Christophe Um fair zu sein, es gibt viel bessere Ansätze für ein Problem, als UB völlig unangefochten zu lassen, wie C und C ++. Sie könnten eine sichere, verwaltete Sprache haben, mit Notausschlüssen in unsicheres Gebiet, damit Sie sich bewerben können, wo dies von Vorteil ist. TBH, es wäre wirklich schön, wenn ich mein C / C ++ - Programm einfach mit einem Flag kompilieren könnte, das besagt: "Fügen Sie die teuren Runtime-Maschinen ein, die Sie benötigen, es ist mir egal, aber erzählen Sie mir nur von ALLEN auftretenden UB . "
Alexander

4
Ein gutes Beispiel für eine Datenstruktur, die absichtlich nicht initialisierte Positionen liest, ist Briggs und Torczons spärliche Mengenrepräsentation (siehe beispielsweise codingplayground.blogspot.com/2009/03/… ). Die Initialisierung einer solchen Menge ist O (1) in C, aber O ( n) mit der erzwungenen Initialisierung von Java.
Arch D. Robison

9
Das Erzwingen der Initialisierung von Daten macht defekte Programme zwar viel vorhersehbarer, garantiert jedoch nicht das beabsichtigte Verhalten: Wenn der Algorithmus erwartet, dass er aussagekräftige Daten liest, während er die implizit initialisierte Null fälschlicherweise liest, ist dies ein ebenso großer Fehler wie ein Fehler Lies etwas Müll. Mit einem C / C ++ - Programm wäre ein solcher Fehler sichtbar, wenn der Prozess unter ausgeführt valgrindwürde, der genau anzeigt, wo der nicht initialisierte Wert verwendet wurde. Sie können valgrindJava-Code nicht verwenden , da die Laufzeit die Initialisierung valgrindvornimmt und s-Prüfungen unbrauchbar machen.
22.

5
@cmaster Aus diesem Grund können Sie mit dem C # -Compiler nicht von nicht initialisierten Locals lesen. Keine Laufzeitüberprüfungen, keine Initialisierung, nur Analyse zur Kompilierungszeit. Es ist jedoch immer noch ein Kompromiss - es gibt einige Fälle, in denen Sie nicht in der Lage sind, Verzweigungen bei potenziell nicht zugewiesenen Einheimischen vorzunehmen. In der Praxis habe ich keine Fälle gefunden, in denen dies überhaupt kein schlechtes Design war und die besser durch Überdenken des Codes gelöst wurden, um die komplizierte Verzweigung zu vermeiden (die für Menschen schwer zu analysieren ist), aber es ist zumindest möglich.
Luaan

42

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

Verwendung einer nicht initialisierten Variablen: Dies ist allgemein als Problemquelle in C-Programmen bekannt, und es gibt viele Tools, mit denen diese Probleme behoben werden können: von Compiler-Warnungen bis hin zu statischen und dynamischen Analysatoren. Dies verbessert die Leistung, da nicht alle Variablen beim Erreichen des Gültigkeitsbereichs mit Null initialisiert werden müssen (wie dies bei Java der Fall ist). Für die meisten skalaren Variablen würde dies einen geringen Overhead verursachen, aber Stapelanordnungen und Speicher auf der Malloc-Ebene würden einen Memset des Speichers verursachen, was ziemlich kostspielig sein könnte, insbesondere, da der Speicher normalerweise vollständig überschrieben wird.


Vorzeichenbehafteter Integer-Überlauf: Wenn Arithmetik bei einem Typ 'int' überläuft, ist das Ergebnis undefiniert. Ein Beispiel ist, dass "INT_MAX + 1" nicht garantiert INT_MIN ist. Dieses Verhalten aktiviert 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 (da dies undefiniert wäre), können Sie "X * 2/2" auf "X" optimieren. Obwohl dies trivial erscheinen mag, werden diese Dinge häufig durch Inlining und Makroexpansion 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 beim Überlauf genau N + 1-mal iteriert, wenn "i" nicht definiert ist, wodurch eine breite Palette von Schleifenoptimierungen möglich ist Beim Überlauf umbrechen, dann muss der Compiler annehmen, dass die Schleife möglicherweise unendlich ist (was passiert, wenn N INT_MAX ist) - wodurch diese wichtigen Schleifenoptimierungen deaktiviert werden. Dies betrifft insbesondere 64-Bit-Plattformen, da so viel Code "int" als Induktionsvariablen verwendet.


27
Der wahre Grund, warum der Überlauf von vorzeichenbehafteten Ganzzahlen undefiniert ist, ist natürlich, dass bei der Entwicklung von C mindestens drei verschiedene Darstellungen von vorzeichenbehafteten Ganzzahlen verwendet wurden (Einerkomplement, Zweikomplement, Vorzeichengröße und möglicherweise Offset-Binärzahl). und jedes ergibt ein anderes Ergebnis für INT_MAX + 1. Wenn der Überlauf undefiniert ist a + b, kann er add b ain jeder Situation für den nativen Befehl kompiliert werden, anstatt dass ein Compiler möglicherweise eine andere Form der Ganzzahlarithmetik mit Vorzeichen simulieren muss.
Mark

2
Das Ermöglichen eines lose definierten Verhaltens von Ganzzahlüberläufen ermöglicht signifikante Optimierungen in Fällen, in denen alle möglichen Verhalten die Anwendungsanforderungen erfüllen würden . Die meisten dieser Optimierungen verfallen jedoch, wenn Programmierer um jeden Preis Ganzzahlüberläufe vermeiden müssen.
Supercat

5
@supercat Dies ist ein weiterer Grund, warum das Vermeiden von undefiniertem Verhalten in neueren Sprachen häufiger vorkommt - Programmiererzeit wird viel mehr geschätzt als CPU-Zeit. Die Art von Optimierungen, die C dank UB vornehmen darf, ist auf modernen Desktop-Computern im Wesentlichen sinnlos und erschwert das Denken über Code erheblich (ganz zu schweigen von den Sicherheitsaspekten). Sogar in leistungskritischem Code können Sie von Optimierungen auf hoher Ebene profitieren, die in C etwas schwieriger (oder sogar noch schwerer) durchzuführen sind. Ich habe meinen eigenen Software-3D-Renderer in C #, und die Möglichkeit, z HashSet. B. a zu verwenden, ist wunderbar.
23.

2
@supercat: Wrt_loos defined_, die logische Wahl für einen Ganzzahlüberlauf wäre, ein durch die Implementierung definiertes Verhalten zu erfordern . Dies ist ein bestehendes Konzept und stellt keine übermäßige Belastung für die Implementierung dar. Die meisten würden mit "es ist 2's Ergänzung mit Wrap-Around" davonkommen, vermute ich. <<könnte der schwierige Fall sein.
MSalters

@MSalters Es gibt eine einfache und gut untersuchte Lösung, bei der es sich weder um undefiniertes Verhalten noch um implementierungsdefiniertes Verhalten handelt: nicht deterministisches Verhalten. Das heißt, Sie können sagen " x << yWertet auf einen gültigen Wert des Typs aus, int32_taber 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. ...
Mario Carneiro

20

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


Wollen Sie damit sagen, dass Abwärtskompatibilität der einzige Grund ist, warum C und C ++ nicht aus undefinierten Verhaltensweisen herauskommen?
Sisir

3
Es ist definitiv eines der größeren, @Sisir. Selbst unter erfahrenen Programmierern werden Sie überrascht sein, wie viel Material, das nicht unterbrochen werden sollte, unterbrochen wird , wenn ein Compiler ändert, wie es mit undefiniertem Verhalten umgeht. ( Typischer Fall, war es ein bisschen Chaos , wenn GCC gestartet Optimierung out „wird thisüberprüft eine Weile zurück, mit der Begründung , dass null?“ thisSein nullptrUB ist, und somit kann eigentlich nie passieren.)
Justin Time 2 wieder einzusetzen Monica

9
@ Sir, ein weiterer großer Punkt ist die Geschwindigkeit. In den Anfängen von C war Hardware viel heterogener als heute. Indem Sie einfach nicht angeben, was passiert, wenn Sie 1 zu INT_MAX hinzufügen, können Sie den Compiler das tun lassen, was für die Architektur am schnellsten ist (z. B. erzeugt ein Ein-Komplement-System -INT_MAX, während ein Zweier-Komplement-System INT_MIN erzeugt). Wenn Sie nicht angeben, was passiert, wenn Sie nach dem Ende eines Arrays lesen, kann das Programm von einem System mit Speicherschutz beendet werden, ohne dass eine teure Laufzeitüberprüfung durchgeführt werden muss.
Mark

14

JVM- und .NET-Sprachen haben es einfach:

  1. Sie müssen nicht in der Lage sein, direkt mit Hardware zu arbeiten.
  2. Sie müssen nur mit modernen Desktop- und Serversystemen oder vernünftigerweise ähnlichen Geräten oder zumindest für sie entwickelten Geräten zusammenarbeiten.
  3. Sie können die Garbage-Collection für den gesamten Speicher und die erzwungene Initialisierung festlegen, wodurch die Zeigersicherheit erhöht wird.
  4. Sie wurden von einem einzelnen Akteur spezifiziert, der auch die endgültige Umsetzung lieferte.
  5. Sie können Sicherheit vor Leistung entscheiden.

Es gibt jedoch gute Punkte für die Auswahl:

  1. Die Systemprogrammierung ist ein ganz anderes Spiel, und es ist vernünftig, die Anwendungsprogrammierung kompromisslos zu optimieren.
  2. Zugegeben, es gibt immer weniger exotische Hardware, aber kleine eingebettete Systeme sind hier, um zu bleiben.
  3. GC ist für nicht fungible Ressourcen ungeeignet und bietet viel mehr Platz für eine gute Leistung. Die meisten (aber nicht fast alle) erzwungenen Initialisierungen können wegoptimiert werden.
  4. Mehr Wettbewerb hat Vorteile, aber Komitees bedeuten Kompromisse.
  5. Alle diese Grenzen Kontrollen Sie addieren, obwohl die meisten wegoptimiert werden kann. Nullzeigerüberprüfungen können meistens durchgeführt werden, indem der Zugriff aufgrund des virtuellen Adressraums auf null Overhead abgefangen wird, obwohl die Optimierung immer noch unterbunden ist.

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.


3
Tatsächlich. Ich programmiere in C # für meinen Job. Hin und wieder greife ich nach einem der unsicheren Hämmer ( unsafeStichwort oder Attribute in System.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.
Joshua

19
Ich arbeite häufig auf einer Plattform mit analogen Geräten, bei denen sizeof (char) == sizeof (short) == sizeof (int) == sizeof (float) == 1. Es wird auch eine Sättigungsaddition durchgeführt (also INT_MAX + 1 == INT_MAX) , und das Schöne an C ist, dass ich einen konformen Compiler haben kann, der vernünftigen Code generiert. Wenn die von der Sprache vorgeschriebene Aussage lautet, dass zwei mit einem Wrap Around ergänzt werden, endet jede Addition mit einem Test und einer Verzweigung, so etwas wie ein Nichtstarter in einem DSP-fokussierten Teil. Dies ist ein aktueller Produktionsteil.
Dan Mills

5
@BenVoigt Einige von uns leben in einer Welt, in der ein kleiner Computer vielleicht 4 KB Code-Speicherplatz, einen festen Call / Return-Stack mit 8 Ebenen, 64 Byte RAM, einen 1-MHz-Takt und eine Menge von 1.000 US-Dollar umfasst. Ein modernes Mobiltelefon ist ein kleiner PC mit nahezu unbegrenztem Speicherplatz für alle Zwecke und Zwecke und kann als PC behandelt werden. Nicht die ganze Welt ist mehrkernig und es fehlen harte Echtzeitbeschränkungen.
Dan Mills

2
@DanMills: Ich spreche hier nicht über moderne Mobiltelefone mit Arm Cortex A-Prozessoren, sondern über "Feature Phones" (ca. 2002). Ja, 192 KB SRAM sind viel mehr als 64 Byte (was nicht "klein", sondern "winzig" ist), aber 192kB werden seit 30 Jahren auch nicht mehr als "moderner" Desktop oder Server bezeichnet. Auch in diesen Tagen 20 Cent erhalten Sie einen MSP430 mit viel mehr als 64 Bytes SRAM.
Ben Voigt

2
@BenVoigt 192kB war in den letzten 30 Jahren vielleicht kein Desktop, aber ich kann Ihnen versichern, dass es völlig ausreichend ist, Webseiten zu bedienen, was meiner Meinung nach so etwas per Definition zu einem Server macht. Fakt ist, dass dies für eine Menge eingebetteter Anwendungen, die häufig Konfigurationswebserver enthalten, eine durchaus vernünftige (großzügige, gerade) Menge an RAM ist. Sicher, ich verwende Amazon wahrscheinlich nicht, aber ich verwende möglicherweise einen Kühlschrank mit IOT-Crapware auf einem solchen Kern (mit Zeit und Platz). Benötigt niemand dafür gedolmetschte oder JIT-Sprachen?
Dan Mills

8

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.


Was bedeutet ein "großer gemeinsamer Nenner" wörtlich ? Sprechen Sie über Teilmengen oder Obermengen? Meinen Sie wirklich genug gemeinsame Faktoren? Gleicht dies dem kleinsten gemeinsamen Vielfachen oder dem größten gemeinsamen Faktor? Das ist sehr verwirrend für uns Roboter, die kein Straßenjargon sprechen, nur Mathematik. :)
tchrist

@tchrist: Das übliche Verhalten ist eine Teilmenge, aber diese Teilmenge ist ziemlich abstrakt. In vielen Bereichen, die vom gemeinsamen Standard nicht spezifiziert werden, müssen echte Implementierungen eine Wahl treffen. Jetzt sind einige dieser Entscheidungen ziemlich klar und daher von der Implementierung abhängig, andere sind vager. Das Speicherlayout zur Laufzeit ist ein Beispiel: Es muss eine Auswahl geben, aber es ist nicht klar, wie Sie es dokumentieren möchten.
MSalters

2
Das Original C wurde von einem Mann hergestellt. Es gab bereits eine Menge UB. Es wurde sicherlich schlimmer, als C populär wurde, aber UB war von Anfang an dabei. Pascal und Smalltalk hatten weit weniger UB und wurden fast zur gleichen Zeit entwickelt. Der Hauptvorteil von C war, dass es extrem einfach zu portieren war - alle Portabilitätsprobleme wurden an den Anwendungsprogrammierer delegiert: P Ich habe sogar einen einfachen C-Compiler auf meine (virtuelle) CPU portiert; Etwas wie LISP oder Smalltalk zu machen, wäre weitaus aufwändiger gewesen (obwohl ich einen begrenzten Prototyp für eine .NET-Laufzeit hatte :).
23.

@Luaan: Wäre das Kernighan oder Ritchie? Und nein, es hatte kein undefiniertes Verhalten. Ich weiß, dass ich die Originaldokumentation des AT & T-Compilers auf meinem Schreibtisch hatte. Die Implementierung hat genau das getan, was sie getan hat. Es gab keinen Unterschied zwischen nicht spezifiziertem und undefiniertem Verhalten.
MSalters

4
@MSalters Ritchie war der erste Typ. Kernighan trat erst (nicht viel) später bei. Nun, es gab kein "Undefiniertes Verhalten", da dieser Begriff noch nicht existierte. Aber es hatte das gleiche Verhalten, das man heute undefiniert nennen würde. Da C keine Spezifikation hatte, ist auch "unspezifiziert" eine Strecke :) Es war nur etwas, was dem Compiler egal war, und die Details lagen bei den Anwendungsprogrammierern. Es wurde nicht für portable Anwendungen entwickelt , nur der Compiler sollte leicht zu portieren sein.
23.

6

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:

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

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, intdie der natürlichen Größe entsprechen soll, die von der Architektur vorgeschlagen wird. Wenn ich also ein 32-Bit-VAX programmiere, intsollte es wahrscheinlich 32 Bit sein, aber wenn ich ein 36-Bit-Univac programmiere, intsollte 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.


4

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.


Ich habe das Gefühl, dass Sie hier etwas Wichtiges beschrieben haben, aber es entgeht mir. Könnten Sie Ihre Antwort präzisieren? Besonders der zweite Absatz: Es heißt, dass die Bedingungen jetzt und die Bedingungen früher unterschiedlich sind, aber ich verstehe es nicht. was genau hat sich geändert? Auch der "Weg" ist jetzt anders als früher; Vielleicht auch erklären?
Anatolyg

4
Scheint, als würde Ihre Kampagne nicht definiertes Verhalten durch nicht angegebenes Verhalten ersetzen, oder etwas, das noch stärker eingeschränkt ist.
Deduplizierer

1
@anatolyg: Wenn Sie dies noch nicht getan haben, lesen Sie das veröffentlichte C Rationale-Dokument (geben Sie C99 Rationale in Google ein). In den Zeilen 23-29 wird über den "Marktplatz" und in den Zeilen 5-8 darüber gesprochen, was im Hinblick auf die Portabilität beabsichtigt ist. Wie würde ein Chef einer kommerziellen Compilerfirma reagieren, wenn ein Compiler-Schreiber Programmierern, die sich darüber beschwerten, dass der Optimierer den Code gebrochen hat, den jeder andere Compiler nutzbringend gehandhabt hat, dass sein Code "kaputt" ist, weil er Aktionen ausführt, die nicht durch den Standard definiert sind, und weigerte sich, es zu unterstützen, weil das die Fortsetzung fördern würde ...
Supercat

1
... Verwendung solcher Konstrukte? Ein solcher Standpunkt ist auf den Support-Boards von clang und gcc leicht erkennbar und hat dazu beigetragen, die Entwicklung von Intrinsics zu behindern, die eine Optimierung viel einfacher und sicherer ermöglichen könnten, als die defekte Sprache, die gcc und clang unterstützen möchten.
Supercat

1
@supercat: Du verschwendest deinen Atem und beklagst dich bei den Compiler-Anbietern. Warum richten Sie Ihre Bedenken nicht an die Sprachenkomitees? Wenn sie mit Ihnen übereinstimmen, wird eine Errata ausgegeben, mit der Sie die Compiler-Teams über den Kopf schlagen können. Und dieser Prozess ist viel schneller als die Entwicklung einer neuen Version der Sprache. Aber wenn sie nicht einverstanden sind, werden Sie zumindest tatsächliche Gründe haben, während die Compiler-Autoren nur (immer und immer wieder) wiederholen werden Folgen Sie ihrer Entscheidung. "
Ben Voigt

3

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.


Die Situation ist noch schlimmer: Ich glaube nicht, dass der Ausschuss jemals einen Konsens darüber erzielt hat, ob er beschreibend oder vorschreibend sein soll.
Supercat

1

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 intzwischen Systemen variieren. Intist architekturabhängig und wird im Allgemeinen als der schnellste und effizienteste Datentyp ausgewählt, der größer als a ist char.

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.


-5

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.


4
Das ist kein undefiniertes Verhalten der Art, von der wir hier sprechen. Es gibt zwei Arten von "falschen Komparatoren": solche, die eine Gesamtreihenfolge definieren, und solche, die keine haben. Wenn Sie einen Komparator bereitstellen, der die relative Reihenfolge der Elemente konsistent definiert, ist das Verhalten genau definiert. Es ist nur nicht das Verhalten, das der Programmierer gewünscht hat. Wenn Sie einen Komparator angeben, der hinsichtlich der relativen Reihenfolge nicht konsistent ist, ist das Verhalten immer noch genau definiert: Die Sortierfunktion löst eine Ausnahme aus (die wahrscheinlich auch nicht das vom Programmierer gewünschte Verhalten ist).
Mark

2
Was das Ändern von Variablen angeht, werden Rennbedingungen im Allgemeinen nicht als undefiniertes Verhalten betrachtet. Ich weiß nicht genau, wie Java mit Zuweisungen zu freigegebenen Daten umgeht, aber da ich die allgemeine Philosophie der Sprache kenne, bin ich mir ziemlich sicher, dass sie atomar sein muss. Das gleichzeitige Zuweisen von 53 und 71 awä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.
Mark

@Mark Bei Datenblöcken, die größer als die systemeigene Wortgröße des Systems sind (z. B. eine 32-Bit-Variable in einem 16-Bit-Wortgrößensystem), ist eine Architektur möglich, bei der jeder 16-Bit-Teil separat gespeichert werden muss. (SIMD ist eine andere mögliche Situation.) In diesem Fall ist selbst eine einfache Zuweisung auf Quellcode-Ebene nicht unbedingt atomar, es sei denn, der Compiler achtet besonders darauf, dass sie atomar ausgeführt wird.
ein CVn
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.