Home » Tutorials » Sonstiges » Eins null eins null eins null – Wie Delphi intern tickt

Eins null eins null eins null – Wie Delphi intern tickt

Integers und ihre Begleiterscheinungen

Das Rechnen mit Integers – also Repräsentationen von ganzen Zahlen auf dem Computer – bringt gewisse, ungewollte Effekte mit sich. Einer davon sind die sogenannten Über- und Unterläufe (engl.: Over- und Underflows). Dabei reicht der Datentyp plötzlich nicht mehr aus, um das Ergebnis einer Rechenoperation zu speichern. Angenommen wir haben einen acht Bit langen Datentyp. Dieser reicht grundsätzlich aus, um 28 = 256 Zustände zu speichern. Der Byte-Datentyp von Delphi kann die Zahlen von 0 bis 255 speichern. Größere Zahlen können nicht aufgenommen werden. Nehmen wir als Beispiel den folgenden Quellcode.

var b : byte;
begin
  b := 255;
  writeln(b) ;
  inc(b);
  writeln(b);
  readln;
end.

Wir erhalten die Ausgabe
255
0
Was ist hier passiert? Der Byte-Datentyp hat seinen Oberwert von 255 bereits zugewiesen bekommen und wurde dann noch um einen erhöht. In diesem Moment wurde der zulässige Wertebereich verlassen. Es ist ein Überlauf aufgetreten. Die Zahl 255 hat die binäre Darstellung [1111 1111]. Das Inkrementieren würde theoretisch die Zahl 256 erzeugen. Diese hat die
binäre Darstellung [1 0000 0000]. Man bräuchte neun Bit, um diese Zahl zu speichern. Da uns aber nur acht Bit zur Verfügung stehen, wird das höchstwertige Bit verworfen und die übrigen acht Bit behalten. Die Variable wird nunmehr mit [0000 0000] belegt – der binären Darstellung der Null.
Schauen wir uns dazu an, was passiert, wenn wir unterschiedliche Zahlen im Wertebereich des Byte-Datentyps miteinander addieren. Auf der z-Achse ist eingetragen, was das Ergebnis der Addition beider Zahlen ist. Man sieht deutlich die Linie, ab dem der Überlauf auftritt.

Ähnliches kann bei negativen Zahlen auftreten. Bevor wir uns dies anschauen, müssen wir zunächst verstehen, wie negative Zahlen repräsentiert werden. Negative Zahlen werden grundsätzlich im Zweierkomplement gespeichert. Dieses Format ist zunächst etwas umständlich, erlaubt jedoch eine einfachere Subtraktion. Dabei ist -1 als [1111 1111] kodiert (alle Bits gesetzt) und kleinere Werte erhalten jeweils auch kleinere Darstellungen. -2 entspricht etwa [1111 1110], -3 hingegen [1111 1101] usw. Dabei kann man am höchstwertigen Bit
ablesen, ob die Zahl negativ ist. Ist es gesetzt, d.h. 1, ist die Zahl negativ und muss wie dargestellt interpretiert werden. Ist es nicht gesetzt, wird sie wie üblich als positive Zahl interpretiert. Das bedeutet, dass man mit 8 Bit in der Lage ist, entweder die Zahlen von 0 bis 255 darzustellen (vorzeichenlos) oder die Zahlen von -128 bis 127 (vorzeichenbehaftet). Das Zweierkomplement hat sich gegenüber anderen Formaten durchgesetzt, da so die Subtraktion mithilfe des internen Addierwerkes des Prozessors durchgeführt werden kann. Damit lässt sich auch der sogenannte Unterlauf erklären.

var b: byte;
begin
  b := 0;
  writeln(b);
  dec(b);
  writeln(b);
  readln;
end.

Hier erhalten wir die Ausgabe

255
Es wurde die Null dekrementiert. Es kommt korrekterweise die Darstellung der Zahl -1 heraus. Da es sich beim Byte-Datentyp jedoch nicht um einen vorzeichenbehafteten Integer handelt, wird die Zahl im Zweierkomplement als normale, positive Zahl interpretiert. Die Darstellung der -1 (wir erinnern uns: [1111 1111]), entspricht genau der Darstellung der Zahl 255. Anders würde es sich natürlich verhalten, wenn wir statt des Byte-Datentyps den Typ Shortint verwenden würden. Dieser speichert ebenfalls acht Bit an Daten, interpretiert
diese jedoch im Zweierkomplement und kann somit wie erwähnt Zahlen von -128 bis 127 darstellen.

var b: shortint;
begin
  b := 0 ;
  writeln(b);
  dec(b);
  writeln(b) ;
  b := 127;
  writeln(b) ;
  inc(b) ;
  writeln(b) ;
  readln;
end.

Die Ausgabe ist
0
-1
127
-128
Der Fall des Unterlaufs, der eben durch das Dekrementieren aufgetreten ist, kann hier nun nicht passieren. Er würde erst geschehen, wenn wir von der Zahl -128 noch einmal 1 abziehen würden. Das Ganze passiert auch bei anderen Datentypen. Nehmen wir den Smallint-Datentyp. Dieser ist 16 Bit breit und im Gegensatz zum Byte-Datentyp vorzeichenbehaftet, was bedeutet, dass er Zahlen von -32768 bis 32767 darstellen kann. Wir setzen diesen also auf 32767 und inkrementieren ihn. Es passiert wie erwartet – es tritt erneut ein Overflow auf, der Datenbereich wird verlassen und wir erhalten die Zahl -32768. Wir sind
diesem Problem vielleicht sogar schon einmal begegnet. Gerade bei älteren Spielen taucht das Problem auf, dass der Punktestand unglaublich hoch ist und im nächsten Moment dann plötzlich im negativen ist. Dieses Problem tritt häufig bei alten 16-Bit-Spielen auf, die einen 16-Bit-Integer verwenden, um den Spielstand zu speichern.
Wir schauen uns nun noch einmal an, wie die Überläufe bei einem vorzeichenbehafteten Datentyp aussehen. Auf der z-Achse ist erneut das Ergebnis der Addition eingetragen.

Das Aufspüren von Über- und Unterläufen wird dadurch noch erschwert, dass es sogar zu einem mehrfachen Überlauf kommen kann. Multiplizieren wir etwa zwei große, positive Zahlen miteinander, ist es möglich, dass die Zahl so groß wird, dass sie zuerst negativ wird, dann aber sogar wieder positiv. Nehmen wir dazu folgenden Quellcode:

var a, b, c: integer;
begin
  a := 47000;
  b := 46000;
  writeln(a);
  writeln(b);
  c := a * b;
  writeln(c);
  readln;
end.

Für gewöhnlich ist 47000 * 46000 = 2162000000. Unsere Ausgabe für die Multiplikation ist aber 47000 * 46000 = -2132967296. Ein Ergebnis, für das uns unser Mathelehrer gerne geschlagen hätte. Wir stricken diesen Fall nun also weiter und erhöhen den Wert vor der Multiplikation also noch etwas. Mit den Zahlen a = 76000 und b = 65000 erhalten wir das Ergebnis a * b = 645032704. Leider stimmt es nicht annähernd. Es ist also genau das erwähnte Phänomen aufgetreten, dass wir nun auf einen mehrfachen Überlauf gestoßen sind.
Was können wir dagegen tun? Zunächst einmal ist die naheliegende Idee, an kritischen Stellen die Wertebereiche zu überprüfen. Zum Glück bietet Delphi uns die Möglichkeit, bei Über- bzw. Unterläufen Exceptions zu werfen. Diese Option kann im Compiler eingeschaltet werden. Ist das geschehen, können die Ausnahmen wie bekannt behandelt werden.

try
  c := a * b;
  writeln(c) ;
except
  on EIntError do
    writeln('Exception detected');
end;

Die Exceptions können uns zwar unter Umständen Laufzeit kosten (und den Code vergrößern), aber zumindest während der Debug-Phase sollte die Option „Überlaufsprüfung“ eingeschaltet bleiben. So lassen sich ärgerliche Fehler vermeiden bzw. eher aufspüren. Aber aufgepasst. Einige Funktionen nutzen diese Überläufe bewusst aus. Zum Beispiel verwendet die random-Funktion selbige, um Pseudo-Zufallszahlen zu erzeugen.
Es bleibt also zu merken: Die ganzen Zahlen im Computer entsprechen nicht den mathematischen ganzen Zahlen.
Für die ganzen Zahlen gilt etwa zum Beispiel, dass jede Zahl a ein additiv inverses Element besitzt. Dass also ein c der ganzen Zahlen existiert, sodass a + c = 0 gilt. Nehmen wir als Beispiel den Shortint-Datentyp, bei dem die Zahl -128 etwa kein inverses Element besitzt. Dies wäre die Zahl 128 – die jedoch innerhalb des Shortint-Datentyps nicht existiert.

Bevor wir uns den Fließkommazahlen zuwenden, schauen wir uns noch kurz eine Tabelle mit Gegenüberstellung der verschiedenen Integer-Datentypen an. Wir sehen, dass zwei Datentypen zwar denselben Speicherbedarf, jedoch aufgrund der Interpretationen einen unterschiedlichen Wertebereich haben können.

Tabelle 1: Fundamentale Integer-Datentypen
Name Größe (in Bit) Wertebereich
Byte 8 0…255
ShortInt 8 -128…127
Word 16 0…65535
SmallInt 16 -32768…32767
Longword 32 0…4294967295
LongInt 32 -2147483648…2147483647
Int64 64 -263…263 – 1
UInt64 64 0…264 – 1
Tabelle 2: Generische Integer-Datentypen
Name Größe (in Bit) Wertebereich
Cardinal 32 0…4294967295
Integer 32 -2147483648…2147483647

Wie wir sehen gibt zwei Arten von Integer-Datentypen.

Fundamentale Datentypen sind Datentypen, die von der verwendeten CPU und dem Betriebssystem unabhängig sind und sich auch bei verschiedenen Implementierungen von Delphi nicht ändert.
Generische Datentypen sind hingegen Datentypen, die von der verwendeten CPU und dem Betriebssystem abhängig sind. An sich bedeutet das hier nur, dass die generischen Typen (Integer und Cardinal) den fundamentalen Datentypen vorzuziehen sind, da diese schneller von der zugrunde liegenden CPU verarbeitet werden können. Die tatsächlichen Performance-Einbußen sind allerdings bei heutigen Rechnern marginal. Allerdings werden die fundamentalen Datentypen dann verwendet, wenn die genaue Größe des Datentyps wichtig ist. So zum Beispiel beim Speichern oder bei der Übertragung von Dateien.