Undefiniertes Verhalten

Obwohl man es glauben könnte, stammt der Begriff “Undefiniertes Verhalten” (“Undefined behavior”) nicht aus der Politik, sondern ist das Gegenteil des “definierten Verhaltens” in der Programmierung.

Ich hatte mit diesem Begriff lange Zeit so meine Probleme, als ich aus der Assembler, Basic und Pascal Welt zu C und C++ überwechselte.

Heute schätze ich diesen Begriff sehr und sehe ihn als Erfolg moderner Compiler an.


Man sagt zum Beispiel, dass der Zugriff auf den Speicher hinter einem NULL-Pointer verboten sei und zu Abstürzen führt.
Doch das stimmt eben so nicht, es ist einfach “undefiniertes Verhalten.” Oder anders gesagt: Niemand hat definiert, was genau dann passiert und daher kann es auf jedem System zu einer anderen Reaktion kommen.

Unter den heutigen Windows und Linux Releases ist der Speicher um die virtuelle Adresse “NULL” nie mit einem physischen Speicher verbunden. Der Zugriff führt also zu einer “Allgemeinen Schutzverletzung”, die das Programm entweder behandelt oder das Betriebssystem beendet einfach den Prozess - ein klassischer Programmabsturz eben.

Doch zu DOS Zeiten gab es keine “virtuellen Adressen” und da begann der Speicher tatsächlich bei Adresse “0”.
Der Zugriff über einen NULL-Pointer war erfolgreich und lieferte die entsprechenden Bytes darin.
Eben diese Bytes gehören aber zur globalen Interrupt-Vektortabelle und falls da jemand seine eigenen Daten hineinschrieb, kam es zu einem sprichwörtlich undefinierten Verhalten, denn beim nächsten Interrupt wurde aus der Tabelle die Adresse des auszuführenden Codes abgeleitet und der Prozessor sprang dann zu einer beliebigen Adresse.

Wollte aber jemand ganz bewusst eine Interrupt-Routine anpassen, so musste dies zwangsläufig über den NULL-Pointer plus Offset geschehen und das Ergebnis war dann kein Absturz, sondern ein korrekter Programmfluss.

“Definiertes Verhalten” sind also Spielregeln, die plattformübergreifend auf allen Systemen gleich sind. Beim “undefinierten Verhalten” gelten ganz spezielle Spielregeln, die auf jeder Plattform anders aussehen können.

Als C Programmierer sind wir sehr daran interessiert Programme zu schreiben, die quasi überall laufen, und daher wollen wir in der Regel nur mit definiertem Verhalten arbeiten.


C erkennt z.B. Rechenoperationen nur innerhalb der Bitgrenzen eines Typen als “definiert” an.
Doch leider sind wir heute schon derart an die binäre Natur heutiger Prozessoren gewöhnt, dass wir auch Überläufe als “definiert” betrachten, obwohl sie es nicht sind.

1unsigned int x = (unsigned int)-1;

Oft habe ich schon diesen Code gesehen, der alle Bits in x auf 1 setzen soll. Auf einem x86 16-bit System wäre x=65535, unter 32 bit x=4294967295 und mit 64 bits hätten wir x=18446744073709551615. Die meisten C Compiler würden das auch so umsetzen, weil die binäre Entsprechung von -1 sich ohne Zusatzaufwand genau so abbildet.
Doch tatsächlich dürfte der Compiler unser x auch auf jeden anderen Wert setzen, weil das Verhalten eben nicht definiert ist.

Wenn wir mal Quantencomputer oder andere Rechensysteme konstruieren, die nicht auf der heutigen binären Logik aufsetzen, dann ist eine solche Operation eventuell auch gar nicht mehr umsetzbar, oder bräuchte zusätzliche Instruktionen.

Ich wurde schon Kollegen gefragt, warum ich in Implementierungen von Base64 oder anderen Byte-Serialisierungen die Zahlen durch Rechenoperationen zerlege und nicht einfach Typen-Casts benutze, die Bits abschneiden.
Und meine Antwort darauf war oft: weil Rechenoperationen genau definiert sind, Bitabschneidungen sind es nicht.
Und nur weil aktuell alles korrekt zu funktionieren scheint, ist es eben nicht zwingend korrekt.

Zum Glück kann ich mich großteils darauf verlassen, dass moderne Compiler meinen komplexeren Rechencode verstehen und die Optimierungsphase für die Plattform wieder einen performanten Maschinencode (ohne Zusatzrechnungen) daraus generiert.

Doch selbst wenn nicht, so gilt für mich stets, das Korrektheit vor Geschwindigkeit kommt.
Denn was nützt mir der schnellste Code, wenn 1 + 1 nicht mehr 2 ergibt.

Fazit

Also Freunde, achtet stets darauf, was laut Sprachstandard “definiert” ist und versucht “undefiniertes Verhalten” zu vermeiden. Ausgenommen, ihr schreibt Treiber oder OS-Teile, die auf ganz spezielle Plattformen und Compiler zugeschnitten sein müssen.

Raymond Chan hat ein paar nette Beispiele dafür genannt, wo Compiler beim Erkennen von “undefiniertem Verhalten” ihre Codegenerierung abändern.