Daten – oder Zahlen, die die Welt bedeuten
Wir haben jetzt einen groben Einblick in die Grundideen von Software und den Systemen, auf denen sie laufen soll. Was uns jetzt noch fehlt ist, wie wir eigentlich Daten in unserem Speicher ablegen können. Bis jetzt sind es ja nur Zahlen, welche aus der mathematischen Zahlenmenge der Natürlichen Zahlen N0 entstammen und je nach Anzahl der zur Verfügung stehenden Bits nach oben begrenzt sind. Wir wollen aber viel mehr abbilden. Beispielsweise möchten wir gerne negative Zahlen nutzen, Zahlen mit Nachkommanstellen und auch Daten außerhalb des Zahlenbereiches wie Schriftzeichen. Wir brauchen also Konzepte, wie wir unsere Bitfolgen im Speicher nicht nur 1:1 in Dezimalzahlen umwandeln und umgekehrt, sondern wie wir sie auch anderweitig interpretieren können, so dass andere Anwendungsfälle möglich sind.
Top
Zweierkomplement
Beginnen wir mit der Definition von negativen Werten. Wenn wir naiv vorgehen würden, dann könnten wir einfach bei einer 16 Bit Zahl ein Bit für das Vorzeichen „opfern“:

Abb.: 1: "Navier" Ansatz für Vorzeichen im Binärformat
Dieser Ansatz hätte nun zwei entscheidende Nachteile. Erstens gibt es die 0 zweimal, nämlich + und – 0, was unlogisch scheint – zumindest bei ganzen Zahlen. Weiterhin müssten wir eine unnötig komplizierte Logik einbauen, wenn wir eine negative und eine positive Zahl addieren möchten. Mit einer einfachen Addition auf Binärebene würde ein Fehler herauskommen:
Binäre Addition
Abb.: 2: Binäre Addition
Wenn wir binär addieren, schreiben wir wie im Dezimalsystem die Zahlen rechtsbündig untereinander und addieren Stelle für Stelle. Bei der kleinsten Stelle addieren wir 12 + 12, macht 102, weshalb wir die 0 als Ergebnis übernehmen und den Übertrag 1 notieren (…dies würde Dezimal genauso laufen, wenn wir bspw. die 4 plus 6 rechnen würden. Das Ergebnis wäre 10 im Dezimalsystem, weshalb wir unter 4+6 die 0 notieren würden und den Übertrag 1 hätten). In der nächsten Spalte addieren wir 12 + 12 plus den Übertrag 12, macht 112. Dadurch haben wir als Ergebnis 1 und wieder den Übertrag 1. Und so gehen wir alle Bits durch. Das Ergebnis wäre dann 1000.0011.0110.11102. Wenn wir das erste Bit als Vorzeichen interpretieren würden, hätten wir somit als Ergebnis -878, was natürlich nicht der erwarteten 0 entspricht. Insofern ist das Vorzeichenbit für ganze Zahlen erstmal keine gute Idee.
Die Frage ist nun, ob es überhaupt gehen würde, zwei positive Zahlen so zu addieren, dass die 0 herauskommt. Mathematisch ist dies natürlich unmöglich. Allerdings können wir nun von der Beschränktheit der Bitbreite von Speicherzellen profitieren. Wie wäre es denn, wenn das Ergebnis nicht 16 Bitstellen lang wäre, sondern 17, beispielsweise 1.0000.0000.0000.00002 (dezimal 65.536). Da wir nur 16 Stellen speichern können, müssten wir jetzt die linke 1 einfach abschneiden und der Rechner würde jetzt tatsächlich die 0000.0000.0000.00002 ablegen. Das heißt, wir würden die Rechnung 439 + (-439) = 0 umstellen in 439 + (???) = 65.536. Wenn wir nun das ??? ausrechnen, haben wir eine mögliche Darstellung der Zahl -439. Das Bitmuster müsste 65.536 – 439 = 65.097 in Binär sein. Probieren wir es:
Addition von Zweierkomplement
Abb.: 3: Addition von Zweierkomplement
Wenn wir also die Bitkombination 1111.1110.0100.10012 als -439 interpretieren, dann würde die Addition dieser Bitkombination mit 0000.0001.1011.01112 tatsächlich 1.0000.0000.0000.00002 ergeben, wobei der Übertrag auf die 17. Stelle einfach ignoriert werden würde. Diese Darstellung einer negativen Zahl bezeichnet man als „Zweierkomplement“ und ist eine übliche Kodierung von negativen ganzen Zahlen. Die Bildung des Zweierkomplements aus einer Zahl (sprich die Bildung der negativen Darstellung einer Zahl) ist relativ einfach – man invertiert alle Bits (sprich aus jeder 1 wird die 0 und aus jeder 0 wird die 1). Anschließend addiert man noch die 1 und ist fertig.
Was bedeutet dies nun für unsere Zahlenmenge? Schreiben wir doch einfach mal die Wertebereiche hin:
Wertebereich bei 16Bit mit Zweierkomplement
Abb.: 4: Wertebereich bei 16Bit mit Zweierkomplement
Der Wertebereich läuft also von -32.768 bis 32.767, was bedeutet, dass die 216 Kombinationen in eine positive und eine negative Hälfte gesplittet wird, wobei die 0 der positiven Hälfte zugeordnet wird. Deshalb ist hier der größte positive Wert die Hälfte von 216 minus 1. Interessant ist auch, dass wir nun doch wieder ein Bit haben, aus dem wir das negative Vorzeichen extrahieren könnten – wieder das linke Bit. Alle anderen Bits im Bereich der negativen Zahlen werden aber durch das Zweierkomplement gebildet. Wenn wir also eine negative Zahl, bspw. die -35 haben, so ist das der Binärwert 1111.1111.1101.11012. Wir erkennen, dass das erste Bit 1 ist. Also ziehen wir 1 ab, was 1111.1111.1101.11002 ergibt und drehen jedes Bit um. Somit erhalten wir was 0000.0000.0010.00112, den Binärwert für 35.
Die Frage ist nun, was bringt mir als Programmierer dieses Wissen? Hier gibt es zwei Bereiche, in denen dieses Wissen wichtig ist. Der erste Bereich ist, wenn wir in unseren Programmen ganze Zahlen verarbeiten wollen und mit einer Programmiersprache arbeiten, bei denen wir die „Datentypen“ angeben müssen. Hier ist es wichtig zu wissen, welchen Datentyp wir nehmen können, welchen Speicherplatz der ausgewählte Datentyp verwendet und welcher Wertebereich daraus resultiert. Gehen wir mal davon aus, dass wir Zahlen von –1 Milliarde bis +1 Milliarde in einer Variablen erwarten. Hierfür ist der Datentyp „Integer“ (oder oft kurz „int“ genannt) geeignet. Dieser Datentyp hat eine Bitbreite von 32 Bit, wodurch wir 232 = 4.294.967.296 Bitkombinationen abbilden können. Daraus ergibt sich ein Wertebereich von:
- (232 / 2) = -2.147.483.648 bis + (232 / 2) - 1 = +2.147.483.647
Wir werden später noch alle gängigen Datentypen beleuchten. Der zweite wichtige Punkt zu verstehen ist, wie der Computer mit Zahlenüberläufen umgeht. Sehen wir uns hierfür mal folgendes in C geschriebene Programm an:
Listing 1: Überlauf int in C
Kurze Erklärung der Codezeilen: In der ersten Zeile erstellen wir eine Variable vom Datentyp int, wodurch sie den von uns festgestellten Wertebereich hat. Weiterhin schreiben wir den größtmöglichen Wert in die Variable, nämlich „2147483647“. In der nächsten Zeile erhöhen wir den Wert in dieser Variablen um 1. Binär wird folgendes gerechnet:
0111.1111.1111.1111.1111.1111.1111.11112 + 1 = 1000.0000.0000.0000.0000.0000.0000.00002“. Das ist aber eigentlich eine negative Zahl, und zwar die -2.147.483.648. In der letzten Zeile geben wir die Zahl einfach auf der Konsole aus, wobei die Interpretation als Zahl mit Vorzeichen durchgeführt wird (%d).
Wenn wir das Programm starten, sehen wir auf der Konsole folgende Ausgabe:
Wir können also exakt nachvollziehen, warum unsere Variablen ein derartiges, auf den ersten Blick „merkwürdiges“ Verhalten zeigen. Es gibt zwar vereinzelt Systeme, welche bei einem solchen „Überlauf“ einen Fehler generieren. Bei sehr vielen Programmiersprachen können wir aber genau das oben gezeigte Verhalten beobachten. Die Zahlen liegen also in einer Art Kreis. Dies lässt sich sehr schön bei einem fiktiven Datentyp veranschaulichen, der nur 4 Bits zur Verfügung hätte:
Positive und negative Binärzahlen im Zahlenkreis
Abb.: 5: Positive und negative Binärzahlen im Zahlenkreis
Einen Nachteil haben wir aber bei dieser Darstellung von negativen Zahlen. Der Prozessor muss beim größer/kleiner Vergleich das vorderste Bit separat auswerten, da vom Bitmuster her die negativen Zahlen größer sind als die positiven. Die Designer der Computersysteme hatten also die Wahl, die negativen Zahlen oder den Vergleich zu optimieren – im Fall der ganzen Zahlen haben sie sich für die Optimierung der negativen Zahlen entschieden.
Top
Ganzzahlige Typen
Sehen wir uns kurz die gebräuchlichsten Datentypen für ganzzahlige Werte und den zugehörigen Wertebereiche an. Bei den Wertebereichen unterscheiden wir, ob der Datentyp auch negative Werten akzeptieren soll (also mit negativen Vorzeichen, genannt „signed datatype“) oder nur die positiven Werte (also ohne negative Vorzeichen, genannt „unsigned datatype(1)“). Unsigned Datentypen werden – sofern sie unterstützt werden – wie in C durch das Voranstellen von „unsigned“ (also bspw. unsigned int) erstellt oder wie in C# nur durch ein „u“ vor dem Datentyp (also bspw. uint). In manchen Fällen schreibt man bei „signed“ noch ein s davor, wie bspw. in C# bei einem signed byte „sbyte“. Dies liegt daran, dass Bytes im regelfall als unsigned Werte interpretiert werden.
Name: Anzahl Bits: Minwert (signed): Maxwert (signed): Minwert (unsigned): Maxwert (unsigned):
byte 8 -128 127 0 255
short 16 -32.768 32.767 0 65.536
int 32 -2.147.483.648 2.147.483.647 0 4.294.967.296
long 64 -9.223.372.036.854.775.808 9.223.372.036.854.775.807 0 18.446.744.073.709.551.616
Tabelle 1: Die wichtigsten ganzzahligen Datentypen(2)
(2) Die Wertebereiche beziehen sich auf Java, C# und C auf einem 32 Bit System
Die Namen dieser Datentypen sind bei vielen gängigen Programmiersprachen gleich oder zumindest ähnlich. In Datenbanken findet man aber mitunter die Bezeichnungen „tinyint“ für „byte“, „shorting“ und „longint“ (und bei manchen Systemen auch „mediumint“ für Datentypen, die 24 Bit belegen). Im Zweifelsfall einfach das Internet befragen.
Nun müssen wir noch über den Umgang mit diesen Datentypen innerhalb des Codes sprechen. Grundsätzlich gilt ja, dass die Bitrepräsentation auf dem Speicher durch den Computer erstmal interpretiert werden muss. Wenn wir beispielsweise eine Variable auf dem Bildschirm ausgeben wollen, so können wir es wie in unserem obigen C Programm über printf("Wert: %d", zahlVariable); durchführen. Das „%d“ führt zur Interpretation der Zahl als „signed digit“. Wenn wir die Ausgabe anstatt mit „%d“ mit „%u“ durchführen, wird das Bitmuster als „unsigned digit“ ausgegeben. Prüfen wir dies einmal nach:
Listing 2: Signed und unsigned Interpretation von int
Kurze Erklärung der Codezeilen: In der ersten Zeile erstellen wir eine Variable vom Datentyp int, was in C automatisch eine „signed int“ Variable ist. Wir belegen sie mit der -1, was das Bitmuster: 1111.1111.1111.1111.1111.1111.1111.11112 ergibt. Danach geben wir die Zahl über %d aus, somit als signed. Nun geben wir einen Zeilenumbruch mit \n (new line) aus. Am Schluss wird nochmal eine Ausgabe durchgeführt, diesmal mit %u, als unsigned.
Wenn wir das Programm starten, sehen wir auf der Konsole folgende Ausgabe:
Der zweite Wert entspricht also genau dem Bitmuster für 32 mal die 1, wenn wir es nicht als Zweierkomplement interpretieren. Man muss dem Computer in diesem Fall also explizit mitteilen, wie er die Zahl zu interpretieren hat. In Programmiersprachen wie Java hat man letztendlich komplett auf die Umsetzung von unsigned Datentypen verzichtet, wodurch man diesen Problemen von vorneherein aus dem Weg geht. Ob das nun eine Erleichterung oder eher ein Hindernis ist, muss jeder für sich selbst entscheiden.
Der letzte zu verstehende Punkt ist, wie Zahlen im Code gelesen werden. Wir wissen ja bereits, dass alles, was in unserem Code geschrieben und kompiliert wird, während der Ausführungszeit im Speicher landet. Bei Variablen müssen wir den Datentyp, zumindest bei typisierten Sprachen, explizit angeben. Dies erfolgt bei fast allen Programmiersprachen mit dem Voranstellen des Datentyps vor den Variablennamen. Folgender Code sorgt also dafür, dass während der Laufzeit ein Platz im Speicher des Rechners exklusiv für die folgende Variable „reserviert“ wird – man nennt diesen Vorgang „Deklaration“:
Listing 3: Deklaration einer int Variablen
Durch diese Deklaration existiert nun diese Variable – allerdings nur innerhalb des Gültigkeitsbereiches, welcher bei den meisten Programmiersprachen durch geschweifte Klammern definiert wird. Das Klammernpaar in dem die Deklaration erfolgt bestimmt durch die schließende Klammer, wo die Gültigkeit aufhört. In unseren ersten Programmen hatten wir aber erstmal nur ein Klammernpaar, weshalb sich die Fragestellung der Gültigkeit erstmal nicht stellt. Basierend auf dem Datentyp kann der Computer nun feststellen, wie viel Speicherplatz diese Variable benötigt – in unserem Fall „32 Bit“. Weiterhin kann der Rechner jetzt auch den Umgang mit dieser Variablen „planen“. Wenn wir beispielsweise den Inhalt der Variablen mit einer Zahl im Sinne von „größer“ vergleichen, weiß der Rechner aufgrund der Zweierkomplementdarstellung wie er mit negativen Werten umzugehen hat. Allerdings kann der Rechner diesen Vergleich erst dann sinnvoll durchführen, wenn wir in die Variable einen Wert hineinschreiben. Tun wir dies nicht, so würden wir mit einer sogenannten „uninitialisierten“ Variablen arbeiten. Manche Programmiersprachen, wie beispielsweise C, erlaubend dies. Allerdings kommen dann unerwartete Ergebnisse heraus. Folgendes Programm wurde auf meinem Rechner mit einem simplen Compiler kompiliert und zweimal hintereinander gestartet(4):
(4) Der g++ Compiler würde manuell eine Initialisierung mit 0 einfügen, weshalb wir mit diesem Compiler dieses Phänomen nicht nachstellen können.
Listing 4: Deklaration und Ausgabe einer int Variablen ohne Initialisierung in C
Ausgabe nach erstem Start:
Ausgabe nach zweitem Start:
Dies liegt daran, dass durch den Code, der durch den C Compiler erzeugt wird, der Computer angewiesen wird, einen Platz auf dem Speicher zu reservieren. Und genau das macht der Rechner. Wenn nun zufällig in der Speicherzelle der Wert 2514944 steht, dann hat die Variable automatisch auch diesen Wert. Dies zeigt uns auch gleich, was eine „Variable“ eigentlich ist. Sie ist ein Platzhalter in unserem Code, der später zur Laufzeit durch die Adresse des Speicherplatzes ersetzt wird. Da wir während des Programmierens nicht wissen können an welcher Stelle zur Laufzeit der Wert liegen wird, arbeiten wir eben mit diesem Platzhalter.
Deklaration und Speicherallokation
Abb.: 6: Deklaration und Speicherallokation
Wenn wir aber nicht genau sagen, was wir in dieser Speicherzelle haben wollen, so wird einfach das verwendet, was bei einer vorausgegangenen Nutzung dieses Speicherbereiches noch drinnen steht. Bei einem erneuten Start dieser Software ist es dann möglich, dass der Computer einen ganz anderen Speicherplatz für diese Variable aussucht, weshalb dann auch ein anderer Wert zu beobachten ist. Andere Programmiersprachen würden diesen Code – also Deklaration der Variablen ohne Wertzuweisung und anschließendes Auslesen der Variablen – bereits beim Kompilieren nicht akzeptieren. Folgender Javacode würde also nicht kompilierbar sein:
Listing 5: Deklaration und Ausgabe einer int Variablen ohne Initialisierung in Java
Erst wenn wir eine initiale Belegung des Wertes vorsehen, wird es funktionieren:
Listing 6: Deklaration, Initialisierung und Ausgabe einer int Variablen in Java
Da diese initiale Belegung so wichtig ist, hat sie auch einen eigenen Begriff bekommen: „Initialisierung“. Meist macht man die Initialisierung in der gleichen Zeile wie die Deklaration:
Listing 7: Kombination Deklaration und Initialisierung in Java
Jetzt fehlt uns nur noch ein Puzzleteil für das Verständnis beim Umgang mit ganzen Zahlen. Wenn wir davon ausgehen, dass alles, was in unseren Programmen vorhanden ist im Speicher abgelegt werden muss, dann benötigen wir streng genommen für den folgenden Code mindestens zwei Speicherzellen:
Listing 8: Kombinierte Rechnung und Ausgabe in Java
Eine wird im Datenspeicherbereich für die Variable benötigt – so viel ist sicher. Es gibt aber noch eine zweite. Im Programmspeicherbereich muss auch noch der Wert „3“ hinterlegt werden. Und für diesen Wert gilt das gleiche wie für die Variablen. Sie muss einen Datentyp haben! Nun haben wir aber keinen Datentyp für diese Zahl deklariert. Woher soll nun der Rechner wissen, welcher Datentyp hier gemeint ist? Die Antwort ist – er kann es nicht wissen. Es wäre zwar möglich, aus dem Kontext dies zu „erraten“ aber die Lösung dieses Problems ist viel profaner. Die meisten Programmiersprachen gehen einfach von einem Standardfall aus. Bei ganzzahligen Datentypen ist dies meist int. Wenn wir die obere Codezeile also ansehen, dann wird die 3 intern einfach als int Wert angenommen und als Konstantwert abgelegt. Wir werden später noch über das Thema „Typecast“ sprechen, worunter wir die Umwandlung der Werte von dem einen Datentypen in einen anderen verstehen. Dieses Phänomen ist streng genommen auch für die Nutzung von Konstantwerten bei der Initialisierung wichtig. Für jetzt genügt erstmal die Vorstellung, dass jede konstant angegebene Zahl ohne Nachkommastellen im Code als int interpretiert wird(5)
(5) Diese Angaben gelten für die meisten Programmiersprachen.
Wenn dem aber so ist, dann müssten wir spätestens mit diesem Code in Java ein Problem bekommen:
Listing 9: Fehlerhafte long Zuweisung in Java
Und in der Tat gibt dieser Code in Java einen Compilerfehler. Der Hintergrund ist, dass die konstanten Zahlen im Code ja als int interpretiert werden und der maximale Wert von int ist nun mal 2.147.483.647, was eindeutig kleiner ist als 10.000.000.000. Um nun trotzdem eine long Variable mit einem größeren Wert als 2.147.483.647 konstant belegen zu können, müssen wir hinter der Zahl ein „l“ für „long“ anhängen. Dadurch wird die Konstante als long interpretiert:
Listing 10: Korrekte long Zuweisung in Java
Top
Gleitkommazahlen
Damit haben wir alles Notwendige für ganzzahlige Werte in unseren Programmen verstanden. Was wir nun noch brauchen, ist eine Darstellung für Zahlen mit einem Komma. Für die Mathematiker unter uns: leider kann der Computer keine reellen Zahlen aus der Zahlenmenge verwenden – wir müssen uns mit rationalen Zahlen aus der Menge begnügen. Das liegt im Wesentlichen wieder an der Beschränktheit unserer Datentypen. Sehen wir uns erstmal den Aufbau von Kommazahlen im Dezimalsystem an. Wenn wir beispielsweise die Zahl 342,284 nehmen, so können wir diese als Summe der einzelnen Ziffern multipliziert mit dem Stellenwert darstellen:
Zerlegung einer Dezimalbruchzahl in die Einzelwerte
Abb.: 7: Zerlegung einer Dezimalbruchzahl in die Einzelwerte
Machen wir mal ein ähnliches Beispiel im Binärformat. Nehmen wir bspw. die Zahl 1011,1012 und zerlegen sie entsprechend:
Zerlegung einer Binärbruchzahl und Umrechnung in Dezimal
Abb.: 8: Zerlegung einer Binärbruchzahl und Umrechnung in Dezimal
Wie wir sehen, ist der Algorithmus zur Umrechnung der gleiche, wir tauschen wieder lediglich die Basis aus. Wir sind also nun in der Lage, auch Binärbruchzahlen (also Zahlen mit Komma) in Dezimal umzuwandeln. Das „unschöne“ der oben gezeigten Darstellung ist, dass wir dem Rechner mitteilen müssten, wie viele Vor- und Nachkommastellen vorzusehen sind. Hier hilft uns jetzt die wissenschaftliche Zahlendarstellung in Exponentenschreibweise. Die Dezimalbruchzahl 342,284 kann auch wie folgt dargestellt werden:
342,284 = 3,42284 x 102
Diese Schreibweise hat zwei Informationen. Erstmal die Ziffernstellen vor dem „x“, welche man auch als „Mantisse“ bezeichnet. Die Mantisse hat vor dem Komma immer eine einzige Stelle ungleich 0, in unserem Fall 3,42284. Die Zehnerpotenz gibt lediglich an, um wie viele Stellen das Komma nach rechts (positiver Exponent) oder nach links (negativer Exponent) zu verschieben ist. In unserem Fall sind es zwei Stellen nach rechts, wodurch die ursprüngliche Zahl 342,284 herauskommt. Dies sollte somit auch erklären, warum es nicht sinnvoll ist, bei der Mantisse vor dem Komma eine 0 zu platzieren. Bei 0,342284 x 103 wäre die Angabe zwar richtig, aber nicht normiert, genauso wie 0,000342284 x 106 oder 342284,0 x 10-3. Man würde die Schreibweise mit Exponent und Mantisse auch als „Fließkommazahl“ oder im Englischen „floating point number“ bezeichnen, weshalb ein möglicher Datentyp für solche Zahlen auch „float“ heißt(6).
(6) Im Gegensatz zu „Festkommazahlen“, bei denen die Anzahl der Nachkommastellen fixiert ist.
Übertragen wir diesen Gedanken mal in das Binärsystem. Wir geben die Mantisse an, indem wir vor dem Komma eine einzige Stelle ungleich 0 setzen. Das ist aber im Binärsystem immer die 1, da es neben der 0 ja nur die 1 gibt. Diesen Sachverhalt nutzt der Computer, indem er die führende 1 gar nicht speichert – er weiß ja, dass es die 1 sein muss – sie wird also implizit angenommen. Dadurch können wir zwar theoretisch keine „0“ mehr darstellen, aber dafür zeige ich gleich noch eine Lösung. Nun wird die Position des Kommas angegeben. Unsere Zahl 1011,1012 würde also (1),011101 x 23 lauten. Das bedeutet somit, wir benötigen mindestens zwei Informationen in unserer Speicherzelle des Computers, die Mantisse und den Exponenten. Jetzt gibt es aber noch zwei Besonderheiten, welche die Vorzeichen angehen. Beginnen wir mit dem Exponenten. Dieser kann ja positiv oder negativ sein. Der Ansatz, negative Exponenten mit dem Zweierkomplement abzubilden geht man hier nicht, sondern man nimmt die Binärzahl und zieht einfach einen festen Wert ab, bei einfachen Fließkommazahlen ist dies der Wert 127. Wenn wir bspw. im Speicherbereich des Exponenten den Wert 130 binär abgelegt haben, dann ist der Exponent 130 – 127 = 3 – also 23. Dies hat den Vorteil, dass die Vergleichbarkeit bezüglich Größe von Zahlen einfacher ist. Dies ist also eine andere Priorisierung als bei den ganzen Zahlen, wo der simplere Umgang mit negativen Zahlen wichtiger war. Die -3 würde bspw. mit der 124 abgelegt werden und der Vergleich -3 < 3 würde im Rechner mit 124 < 130 durchgeführt werden. Ein weiterer Punkt ist das Vorzeichen der Mantisse. Auch diese ist nicht im Zweierkomplement abgelegt. Man hat sich hier für ein Vorzeichenbit entschieden, weshalb es tatsächlich die +0 und die -0 gibt – hier wieder für die Mathematiker interessant, welche durchaus die positive oder negative 0 kennen (Limes 0 von negativer Seite). In Java kann man nun in der Tat die -0.0 provozieren:
Listing 11: Negative 0 bei Floatvariablen in Java
Wenn dieser Code ausgeführt wird, sehen wir auf der Konsole tatsächlich -0.0. Das hat zwar wenig Relevanz für die Praxis, beweist aber den inneren Aufbau von float Datentypen. Würden wir die zahlVariable als „int“ deklarieren, würde die Ausgabe nur 0 sein. Sehen wir uns der Vollständigkeit halber den Aufbau einer 32 Bit Gleitkommazahl nun mal an:
Aufbau 32 Bit Float Zahl
Abb.: 9: Aufbau 32 Bit Float Zahl
Es gibt nun noch ein paar Sonderfälle, welche als festes Bitmuster definiert wurden:
Zahl: Vorz.: Exponent: Mantisse: Erklärung:
0.0 0 0 0 Positive 0 (Achtung – die implizite 1 wird ignoriert!)
-0.0 1 0 0 Negative 0 (Achtung – die implizite 1 wird ignoriert!)
NaN 0 oder 1 255 ungleich 0 Keine gültige Zahl (Not a Number)
Infinity 0 255 0 Plus Unendlich
-Infinity 1 255 0 Minus Unendlich
Tabelle 2: Sonderwerte bei float Zahlen
Interessant ist hier, dass wir sowohl unendlich als auch „NaN“ (spricht „Not a Number“) abbilden können. Vor allem die Option von +/- Unendlich bietet jetzt die Möglichkeit, die Rechnung 1.0/0.0 auszugeben. Mathematisch ist es eigentlich nicht definiert, aber bspw. die Java Macher lassen hier bei der Ausgabe auf der Konsole „Infinity“ erscheinen. Ob dies nun sinnvoll ist oder nicht – sprich ob man diese Funktionalität nutzt – muss wieder mal jeder für sich entscheiden. Gehen wir nun mal auf die eigentlichen Besonderheiten von Gleitkommazahlen ein, die uns beim Programmieren durchaus Kopfzerbrechen bereiten können. Dazu müssen wir vorab darüber sprechen, wie der Computer aus einer Dezimalzahl eine Binärzahl erstellt. Da wir mit Gleitkommazahlen arbeiten, würde die Erklärung des Algorithmus für die Nachkommastellen genügen, der Vollständigkeit halber möchte ich aber über die ganzen Zahlen und über die Nachkommastellen sprechen.
Eine Grundüberlegung in der Mathematik ist, dass wir zwar mit Zahlen arbeiten und dort beliebige Rechenverfahren anwenden können – die Darstellungsart der Zahlen hierbei jedoch irrelevant ist. Frei nach dem Motto 5 + 4 = 9 ist genauso richtig wie V + IV = IX oder 1012 + 1002 = 10012. Insofern können wir die Algorithmen erstmal in unserem vertrauten Zahlensystem, dem Dezimalsystem ansehen, bevor wir sie in das Binärsystem übertragen. Ich möchte also wissen, welche Dezimalziffern in einer Zahl stecken – sagen wir die 342. Natürlich sehen wir die Ziffern durch einen einzigen Blick, schließlich liegt die Zahl ja im Dezimalsystem vor. Trotzdem überlegen wir uns einen Algorithmus, der uns Ziffer für Ziffer liefert. Hierzu benötigen wir einen speziellen Rechenoperator, genannt „Restoperation“. Für diejenigen, die diesen Operator nicht kennen – keine Angst – ihr kennt ihn sehr wohl, nur unter einem längeren Namen, eben der „Rest der ganzzahligen Division“, so wie es in der Grundschule gelehrt wird. Dort haben wir gesehen, dass beispielsweise 23 : 7 = 3, Rest 2 ist. Rest 2 deshalb, weil die 7 dreimal in 23 reinpasst, was 3x7 = 21 ergibt. 23 – 21 wiederum ergibt 2, genannt „Rest“ oder englisch „Remainder“. Deshalb kann man schreiben, 23 Rem 7 = 2. In vielen Programmiersprachen ist das Symbol hierfür das Prozentzeichen „%“, weshalb in Computerprogrammen geschrieben wird: 23%7=2. Dieser „Restoperator“ wir gerne auch als „Modulo“ Bezeichnet, was allerdings nur für positive Zahlen gilt. Da es aber von den Begriffen her klarer (und vor allem gebräuchlicher) ist, verwende ich ab hier den Begriff „Modulo“ und werde bei den Operatoren noch Näheres klären. Nun können wir also einen kleinen Algorithmus definieren, der uns die einzelnen Zahlen auswirft:
Extraktion einzelner Dezimalstellen
Abb.: 10: Extraktion einzelner Dezimalstellen
Gehen wir das Ganze mal durch. Wir sind im Zehnersystem und haben somit die Basis = 10. Dann legen wir eine Zahl in „i“ fest. „i“ steht im Regelfall für eine int Zahl – sprich eine Zahl vom Datentyp Integer und somit eine Zahl ohne Nachkommastellen. Dann spielen wir den Algorithmus mal durch.
  • Setzen der Basis auf 10
  • Ablegen der Zahl 324 in Variable i
  • i = 324 modulo 10 ergibt: 4 (aha – dies ist also unsere erste extrahierte Ziffer)
  • Ausgabe ist: 4
  • i = 324 – 4 ergibt 320 (wir haben also „nur“ den Ziffernwert entfernt)
  • i = 320 / 10 ergibt 32 (somit wurde das „Komma“ um eine Stelle nach links verschoben)
  • i ist nicht 0, weshalb der Algorithmus wiederholt wird
  • i = 32 modulo 10 ergibt: 2 (plus Ausgabe 2, die zweite extrahierte Ziffer)
  • i = 32 – 2 = 30
  • i = 30 / 10 = 3
  • i ist nicht 0, weshalb der Algorithmus wiederholt wird
  • i = 3 modulo 10 ergibt: 3 (plus Ausgabe 3, der dritten extrahierten Ziffer)
  • i = i – 3 = 0
  • i = 0 / 10 = 0
  • i ist 0, weshalb der Algorithmus endet
Nun haben wir auf eine zugegebenermaßen recht komplizierte Art die drei Ziffern unserer Zahl 324 extrahiert. Der Vorteil unseres Algorithmus ist aber, dass wir jetzt lediglich die Basis austauschen müssen, um die Umrechnung in einem anderen Zahlensystem zu realisieren. Im Binärsystem wäre die Basis nun 2. Ich spare mir an dieser Stelle die nochmalige Abbildung des Algorithmus mit der ausgetauschten 2 als Basis. Dafür zeige ich das Java Programm, welches diesen Algorithmus umsetzt:
Listing 12: Algorithmus für ganzzahlige Binärumwandlung in Java umgesetzt
Kurze Erklärung der Codezeilen: Die Deklaration und Initialisierung von ganzzahligen Datentypen dürfte inzwischen klar sein. Das do zusammen mit dem while ist eine fußgesteuerte Wiederholungsschleife. Sie gehört zu den „Kontrollstrukturen“, welche wir später genauer behandeln werden. Was hier passiert ist, dass der Code innerhalb der geschweiften Klammern (des „Schleifenrumpfes“) so lange ausgeführt wird, solange die Bedingung nach dem while erfüllt ist, nämlich i!=0 (das bedeutet „i ungleich 0“). Innerhalb des Schleifenrumpfes wird eine Variable für die auszugebende Ziffer deklariert und über modulo berechnet, dann mit „print“ ausgegeben, was in Java eine Ausgabe ohne Zeilenumbruch ist. Anschließend wird i entsprechend des Algorithmus angepasst.
Die Ausgabe auf der Konsole ist entsprechend:
Der Wert ist nun rückwärts zu lesen, also 1010001002, was bei Umrechnung wieder die 324 ergibt. Soweit ein relativ einfacher und effektiver Algorithmus für die Umrechnung in andere Zahlensysteme. Nun fehlen uns noch die Nachkommastellen. Gehen wir diesmal wieder von einer Dezimalzahl aus, der 0,875. Wir wollen nun wieder einen Algorithmus untersuchen, der uns die einzelnen Ziffern liefert:
Algorithmus zur Extraktion von Nachkommastellen
Abb.: 11: Algorithmus zur Extraktion von Nachkommastellen
Gehen wir auch hier den Algorithmus Schritt für Schritt durch:
  • Setzen der Basis auf 10
  • Ablegen der Gleitkommazahl 0,875 in f
  • f wird gesetzt als 0,875 x 10 = 8,75
  • Abschneiden der Nachkommastellen („floor“) der Zahl f = 8 (das ist nun die extrahierte Ziffer)
  • Ausgabe der Ziffer 8
  • f wird gesetzt als 8,75 – 8 = 0,75
  • f ungleich 0 weshalb die Schleife wiederholt wird
  • f wird gesetzt als 0,75 * 10 = 7,5
  • Ziffer wird durch Abschneiden auf 7 gesetzt und ausgegeben
  • f wird gesetzt auf 7,5 – 7 = 0,5
  • f ist ungleich 0, also Wiederholung
  • f wird gesetzt als 0,5 * 10 = 5,0
  • Ziffer wird durch Abschneiden (auch wenn es nicht notwendig ist) auf 5 gesetzt und ausgegeben
  • f wird gesetzt auf 5,0 – 5 = 0
  • f ist 0, das Programm endet
Wir haben also die Ziffern 8, 7 und 5 extrahiert. Nun können wir das gleiche wieder mit der Basis 2 durchführen. Auch hier zeige ich eine Java Implementierung des Algorithmus mit Basis 2:
Listing 13: Algorithmus für ganzzahlige Binärumwandlung
Kurze Erklärung der Codezeilen: Im Prinzip funktioniert der Code wie im Listing 12. Das „f“ nach der 0.875 ist die Indikation, dass wir eine float Konstante nutzen. Das ansonsten einzig zu erwähnende ist die Zeile int ziffer = (int)f;. Hier wird der Variableninhalt der float Variablen „f“ in eine int Variable ziffer gezwungen. Da int keine Nachkommastellen abbilden kann, wird der Nachkommaanteil einfach abgeschnitten.
Wenn wir den Code nun laufen lassen, erhalten wir:
Eigentlich ist es ja 0,1112 Wenn wir nun die Werte 1x2-1 = 0,5, 1x2-2 = 0,25 und 1x2-3 = 0,125 zusammenaddieren, erhalten wir wieder 0,5 + 0,25 + 0,125 = 0,875. So weit, so gut! Jetzt ändern wir aber unsere Gleitkommazahl „f“ auf 0,4 und sehen was passiert:
Das ist erst einmal eine „Überraschung“. Doch was steckt hier dahinter? Gehen wir die Rechnung mal Schritt für Schritt durch:
  • 0,4 x 2 = 0,8. Abgerundet gibt das die Ziffer 0
  • 0,8 – 0 = 0,8
  • 0,8 x 2 = 1,6. Abgerundet gibt das die Ziffer 1
  • 1,6 – 1 = 0,6
  • 0,6 x 2 = 1,2. Abgerundet gibt das die Ziffer 1
  • 1,2 – 1 = 0,2
  • 0,2 x 2 = 0,4. Abgerundet gibt das die Ziffer 0
Und jetzt kommt das Problem! Wir fangen nun wieder von vorne an! Rein theoretisch würden wir unendlich weiterrechnen (der Computer hat allerdings abgebrochen, da die interne Zahlendarstellung ja begrenzt ist). Aber was heißt das eigentlich? Nun die eigentliche Aussage ist, dass im Binärsystem die Zahl 0,4 eigentlich als 0,01102 sprich „null Komma Periode null, eins, eins, null“ ist. Das heißt, wir würden eigentlich unendlich viele Stellen für die Nachkommastellen benötigen. Dies ist aber aufgrund der Begrenztheit des Speichers nicht möglich. Intern wird die Zahl (100)100110011001100110011012 dargestellt, was eigentlich 0.4000000059604644775390625 ergibt. Diesen „Fehler“ können wir in der eigentlichen Ausgabe von 0,4 zwar nicht sehen, da sich der Computer um eine korrekte Ausgabe durch interne Rundungsprozesse kümmert, aber wir können diese „Bemühung“ austricksen, wenn wir einfach folgenden Code ausführen:
Listing 14: Rundungsfehler bei Gleitkommaoperationen
Die Konsolenausgabe ist hier:
Eine Rechnung, die wir ohne Probleme im Kopf korrekt ausrechnen können, führt aufgrund der Ablage im Binärsystem plötzlich zu einem sehr merkwürdigen Rundungsfehler! Dies bedeutet, dass wir bei der Nutzung von Gleitkommazahlen in unseren Programmen dem Ergebnis nur bis zu einem gewissen Genauigkeitsgrad trauen können. Das ist zwar erstmal erschütternd, wirkt sich aber nur bedingt aus, da der prozentuale Fehleranteil sehr gering ist. Sehen wir uns nun mal die beiden typischen Gleitkomma-Datentypen und den ungefähren Wertebereichen an, wie sie auf den meisten Rechnersystemen zu erwarten sind:
Name: Anzahl Bits: Anzahl Bits (Exp.): Anzahl Bits (Mant.)(7): Wertebereich Exponent: Wertebereich Mantisse: Anzahl Stellen Mantisse:
float 32 8 23+1 10+/-38 +/- 3,4 7
double 64 11 52+1 10+/-308 +/- 1,7 11
Tabelle 3: Ungefähre Wertebereiche von Gleitkommazahlen Datentypen
(7) Anzahl immer + 1, da das implizite Bit mitgerechnet werden muss.
Wir haben also im Regelfall zwei verschiedene Datentypen für Gleitkommazahlen. Wenn wir uns an die ganzzahligen Datentypen erinnern, war „int“ der Standardfall für Konstantwerte im Code, wodurch der Wertebereich für fest im Code hinterlegte Zahlen auf -2.147.483.648 bis 2.147.483.647 festgelegt wurde. Bei größeren Zahlen mussten wir auf „long“ umsteigen und hier erwarten die Programmiersprachen oft eine Ergänzung. In Java war dies das angehängte „l“ (siehe Listing 10). Bei den Gleitkommazahlen ist der Standardfall „double“. Wenn wir also eine Gleitkommazahl im Code hinterlegen, bspw. „0.4“, wird dies im Regelfall als double interpretiert. Dies hat zur Folge, dass float Zahlen auch mit einem Buchstaben markiert werden müssen – sinnigerweise mit f:
Listing 15: Korrekte float Zuweisung in Java
Es dürfte bei dem relativ „komplizierten“ Aufbau von Gleitkommazahlen nun klar sein, warum die Prozessorhersteller für Gleitkommazahlen eigene Hardwarekomponenten im Kern der CPU vorsehen. Wichtig ist jedoch für uns Programmierer, dass die Rechnungen fehlerbehaftet sind und eine vergleichsweise hohe CPU-Last hervorrufen.
Jetzt bleibt noch die Frage, inwieweit wir eine Rechnung 0,4 * 0,1 ohne einen Rundungsfehler realisieren können. Eine bereits angedachte Möglichkeit ist, dass wir einfach eine double Variable nutzen und bei der Multiplikation von zwei Zahlen mit n Nachkommastellen auf n+n Nachkommastellen runden. Bei 0.4 (also eine Nachkommastelle) * 0.1 (auch eine Nachkommastelle) runden wir auf 1+1 = 2 Nachkommastellen. Hier wäre ein beispielhafter Code in Java:
Listing 16: Rundungsfehler in Java vermeiden
Wenn wir hier die Konsolenausgabe ansehen, haben wir den korrekten Wert 0.4.
Kurze Erklärung der Codezeilen: Math.round() rundet den Inhalt der Klammer auf die erste Stelle vor dem Komma, also hat das Ergebnis von Math.round() keine Nachkommastellen mehr. Wenn wir nun zwei Stellen nach dem Komma runden wollen, müssen wir zuerst das Komma um zwei Stellen mit * 100 verschieben. So wird bspw. die 0,4000001 auf 40,00001 welche dann zu 40.0 gerundet wird. In der nächsten Zeile dividieren wir durch 100, wodurch wir das Komma wieder um zwei Stellen zurückschieben. Die gerundete 40,0 wird dadurch wieder zur 0,04. Dadurch haben wir auf zwei Stellen nach dem Komma gerundet.
Top
Sonstige Zahlentypen
Eine weitere Möglichkeit sind spezielle Datentypen. In Java gibt es beispielsweise den Typen „BigDecimal“, mit dem wir korrekt rechnen können, da es sich hier um sogenannte „Festkommazahlen“ handelt. Das Problem dabei ist, dass wir der BigDecimal Variablen nicht einfach einen double oder float Wert übergeben können, da dieser ja bereits systembedingt die Ungenauigkeit mitbringt. Insofern müssen wir die Zahlen mit Vor- und Nachkommastellen konstruieren:
Listing 17: Festkommazahlen in Java
Ich habe hier übrigens auf den Konstruktor BigDecimal("0.4") verzichtet, um das eigentliche Problem hinzuweisen… Die Ausgabe ergibt auch den korrekten Wert 0.4.
Kurze Erklärung der Codezeilen: Bei BigDecimal handelt es sich um keinen einfachen Datentypen mehr, sondern um eine Klasse. Klassen und Objekte werden wir später bei der Objektorientierung näher behandeln. Entscheidend beim Code ist, dass wir die 0,4 nicht direkt an den Konstruktor für die Erzeugung des Objektes übergeben, sondern die 4 (bzw. bei myDecB die 1). Danach erzeugen wir den Divisor als 10. Durch die Division erhalten wir in den beiden Variablen myDecA und myDecB jeweils die korrekten Zahlen 0,4 und 0,1 (jeweils ohne Rundungsfehler). Wenn wir nun diese beiden Zahlen multiplizieren, erhalten wir den korrekten Wert 0,4 auf der Konsole.
Andere Programmiersprachen gehen hier zum Teil andere Wege. C# nutzt beispielsweise einen „decimal“ Datentyp, welcher im Wesentlichen eine 1:1 Abbildung eines Gleitkommazahlentyps im Dezimalsystem darstellt. Um nun diesen Wert überhaupt im Code hinterlegen zu können, muss hier eine neue Kennzeichnung der Zahl mit „m“ erfolgen. Folgender C# Code würde auch wieder die Ausgabe 0.4 erzeugen:
Listing 18: Nutzung des decimal Datentyps in C#
decimal verhindert zwar nicht alle Rundungsprobleme, liefert aber eine sehr viel höhere Anzahl von signifikanten Stellen(8) als double.
(8) 28-29 signifikante Stellen (siehe https://docs.microsoft.com/)
Top
Einzelne Zeichen
Als nächstes kümmern wir uns um Textdaten, die wir im Speicher ablegen wollen. Hier müssen wir vorab aber die kleinste Einheit von Texten, die einzelnen Zeichen (engl. „character“) ansehen. Beginnen wir mit der offensichtlichen Frage, wie wir im Speicher Zeichen ablegen wollen, wenn wir doch nur Zahlen in Binärform ablegen können. Die Antwort ist zwar auf den ersten Blick denkbar einfach, führt aber zu einer Liste an Problemen. Diese „Problemliste“ werden wir endgültig bei der Behandlung von Filezugriffen angehen, aber die Ursache dieser Herausforderung können wir hier bereits sehen. Beginnen wir mit einer offensichtlichen Lösung unseres Zahlen- vs. Zeichen Problems. Wenn ein Computer auf dem Bildschirm einen Text oder Zahlen ausgibt, dann sind das erstmal „nur“ Grafiken – sprich Pixel mit unterschiedlichen Farbwerten. Folgendes Bild zeigt uns die Zahl 3, wie sie von einem Textverarbeitungsprogramm angezeigt wird, allerdings stark vergrößert:
Screenshot Grafik von Zahl
Abb.: 12: Screenshot Grafik von Zahl "3"
Wie wir sehen, sind es einfach nur Quadrate unterschiedlicher Farbe. Für den Computer gibt es diese Zahl „3“ nicht, sie existiert für ihn nur als Bitmuster. Dies bedeutet, dass im Computer irgendwo eine Grafikinformation gespeichert sein muss, um die Zahl 3 so auszugeben, dass wir sie sehen und interpretieren können. Und das gilt für alle Zeichen, die wir zu einem Text zusammenfassen können. Nun muss diese Liste an Grafiken irgendwie sinnvoll geordnet werden, so dass wir kontrolliert und nachvollziehbar darauf zugreifen können. Man hat nun in einer simplen Tabelle festgelegt, welche Zeichen für unsere Texte existieren sollen und diese wurden durchnummeriert. Dadurch haben wir die Zeichen für unseren Speicher zugänglich gemacht. Wir speichern also nicht das eigentliche Zeichen (bzw. die Grafik, welche das Zeichen darstellt), sondern einfach eine Zahl, welche in dieser Zeichentabelle die zugehörige Grafik identifiziert. Streng genommen gibt es hier noch eine weitere Abstraktionsebene. Es wird eigentlich nicht die Grafik referenziert, sondern das Zeichen, für das es Grafiken gibt. Somit können wir unsere Zeichen in verschiedenen Schriftarten ausgeben:
Unterschiedliche Schriftarten
Abb.: 13: Unterschiedliche Schriftarten
Wir haben also zwei „Abstraktionsebenen“, die „Zeichentabelle“ (oder auch „codpage“ genannt) in der wir pro Zeichen die Zahl (oder „ID“) jedes Zeichens ablegen und eine Liste an möglichen Darstellungsarten der Zeichen, die „Schriftarten“:
Zuordnung Zahl, Zeichen und Schriftart
Abb.: 14: Zuordnung Zahl, Zeichen und Schriftart
Die Konsequenz ist, dass wenn eine Schriftart ein bestimmtes Zeichen nicht „kennt“ – sprich keine Grafik dafür gespeichert hat, dieses Zeichen auch nicht dargestellt werden kann. Für die Standardzeichen werden unsere Rechner bestens gerüstet sein, wenn es aber um „exotischere“ Zeichen geht, können manche Schriftarten durchaus dazu übergehen lediglich ein „?“ oder ein nichtssagendes Rechteck zu zeigen. Auf die einzelnen Schriftarten wollen wir erstmal nicht näher eingehen – wir kümmern uns momentan nur um das Wesentliche. Die IDs, welche wir für die einzelnen Zeichen vorsehen, können streng genommen willkürlich definiert werden. Trotzdem ist eine gewisse Struktur sinnvoll – beispielsweise wollen wir eine Sortierbarkeit erreichen, weshalb wir die einzelnen Buchstaben sortiert zuordnen. So ist der Buchstabe „A“ die Zahl 4116 (bzw. in Dezimal 65), „B“ kommt danach, also 4216 und so weiter. Die Kleinen Buchstaben fangen bei 6116 an - man muss also einfach 2016 zum Großbuchstaben hinzuaddieren. Die Ids 0 bis 127 (also die ersten 128 Zeichen) sind in der ASCII Zeichentabelle (American Standard Code for Information Interchange) genormt, welche unter Kapitel XY zu finden ist.
Wir müssen jetzt nur noch festlegen, wie viel Speicherplatz für so eine Zahl der Zeichentabelle vorgesehen sein soll. Damit legen wir auch fest, wie viele Zeichen wir überhaupt unterscheiden können. Im einfachsten Fall sind es 8 Bit pro Zeichen. Dies führt dann zu 28 = 256 verschiedenen Zeichen. Dies mag auf den ersten Blick als ausreichend erscheinen, bei näherer Betrachtung ist es aber eher knapp bemessen. Denken wir bpsw. an die Umlaute, an Zeichen wie ½, ® usw. Ganz zu schweigen von Zeichen für andere Sprachen. Insofern wird es mehrere Tabellen für unterschiedliche Sprachen bzw. Anwendungsfälle geben müssen, wenn wir auf 8 Bit Datenbreite für Zeichen bestehen. Die ersten 128 Zeichen sind jedoch immer entsprechend der ASCII Tabelle einheitlich(9). Dies ist insofern sinnvoll, als dass man hier einen über alle Zeichensätze gemeinsamen Nenner hat. Computercode sollte immer aus dem Zeichenvorrat der ersten 128 Zeichen – sprich dem ASCII Bereich – erstellt werden. Kein ernstzunehmender Programmierer würde auf die Idee kommen, eine Variable „Zähler“ zu nennen, da der Umlaut „ä“ außerhalb des ASCII Bereiches liegt(10). Sobald man den Code auf einem anderen System öffnet auf dem eventuell ein anderer Zeichensatz genutzt wird, würde das „ä“ durch irgendein anderes Sonderzeichen ersetzt werden.
(9) Mit Ausnahme der 12 für die Umdefinition freigegebenen Zeichenpositionen
(10) Zumal man ohnehin Code im Regelfall in Englisch schreibt
Nun sind diese bei 8 Bit möglichen 256 Zeichen für viele anderweitige Anwendungen zu wenig. Aus diesem Grund hat man angefangen Datentypen für Zeichen zu definieren, welche den Zeichenvorrat entsprechend erhöhen. Die für die Zukunft am sichersten funktionierende Definition von Zeichentabellen ist der „Unicode“ Standard, in dem man versucht alle Zeichen – und das beinhaltet übrigens auch alle asiatischen Schriftzeichen – in einer Norm zu hinterlegen. Die Anzahl der katalogisierten Zeichen steigt stetig an. Unter https://unicode.org/ findet man nähere Informationen über den aktuellen Stand. Die Computersysteme versuchen nun für ihre interne Verwaltung genau diese Struktur abzubilden. Hieraus entstanden die „UTF“ Zeichentabellen. Allen voran ist UTF8 (8-Bit UCS Transformation Format(11)) zu nennen, welches wohl die am häufigsten genutzte Abbildung von Unicode in Computersystemen ist. UTF8 hat jedoch eine variable Größe – sprich Zeichen aus dem ASCII Bereich benötigen 1 Byte, alle anderen 2 bis 4 Bytes Speicherplatz. Dies ist für den Datentransport durchaus sinnvoll (und wird hier auch intensiv eingesetzt). Für das Ablegen von Daten im Rechenspeicher ist es jedoch aufgrund der Größenvarianz der Speicherzellen umständlich. Die meisten Programmiersprachen nutzen deshalb die UTF16 Codierung, bei der entweder 2 oder 4 Bytes verwendet werden. Dadurch kann man für die meisten Anwendungsfälle die Zeichen in einem 2 Byte Datentyp speichern, was 216 = 65.536 mögliche Zeichen ergibt. Wenn wir Zeichen aus dem 4 Byte Bereich benötigen, werden meist aufwändigere ergänzende Techniken benötigt – es werden über ein sogenanntes „surrogate pair“ (dt. „Ersatzpaar“) die zusätzlichen Zeichen adressierbar gemacht. Da für uns jedoch die 65.536 verschiedenen Zeichen aus dem 2 Byte Datentyp erstmal mehr als ausreichend(12) sein werden, kümmern wir uns erstmal nicht um die höheren Werte.
(11) UCS: Universal Coded Character Set, wobei das “U” in UTF oft auch als „Unicode“ bezeichnet wird
(12) Die Konsole unserer Windows Version würde ohnehin für die meisten der ersten 32 tausend Unicode Zeichen keine Grafik vorsehen.
Mit dieser Überlegung haben wir – zumindest für die meisten Programmiersprachen – einen Speicherbedarf für einzelne Zeichen gefunden, nämlich 2 Bytes oder eben 16 Bit. Dieser Datentyp wird meist mit „char“ für „Character“ (dt. „Zeichen“) benannt und ist vom inneren Aufbau her nichts anderes als ein Datentyp für ganze Zahlen – und zwar unsigend, sprich es gibt nur positive Zahlen. Lediglich bei C würde er „nur“ 8 Bit breit sein. Jetzt müssen wir nur noch klären, wie wir in diese Variablen auch Datenwerte speichern können. Wie unterscheidet der Computer im Programmcode ein funktionales Zeichen, wie beispielsweise das Istgleich „=“ für die Zuweisung und ein „=“ Zeichen, welches ich in einer char Variablen ablegen möchte? Diese Unterscheidung ermöglichen die Anführungsstriche. Sehen wir uns folgenden Code an (der übrigens für die erste Zeile in Java, C, C++ und C# gleichermaßen aussieht):
Listing 19: Zuweisung eines Konstantwertes in eine char Variable plus Ausgabe in C
Die Ausführung zeigt uns ein „a“ am Bildschirm. Einfache Anführungsstriche werden in fast allen Programmiersprachen als einzelnes Zeichen für eine char Variable gewertet. Hier ist aber Vorsicht geboten. Texteditoren, welche nicht für das Erstellen von technischem Code ausgerichtet sind, versuchen oftmals die einfachen Anführungsstriche durch optisch ansprechendere zu ersetzen, die mitunter auch außerhalb des ASCII Bereiches liegen. Man schreibt Computer Code also immer mit den dafür vorgesehenen Tools!
Wenn man sich nun die Ausgabeformatierung von printf ansieht erkennt man, dass %c hinterlegt werden musste, damit char auch als Zeichen ausgegeben wird. Wenn wir das %c auf das uns bekannte „%d“ – also Zahlenausgabe – tauschen, dann gibt uns der Rechner nicht mehr ein „a“, sondern die Zahl 97 – was der ASCII Code für das Zeichen „a“ ist. Char Variablen sind also wirklich nichts anderes als Zahlenvariablen, nur dass sie nicht negativ werden können und vom Computer beim Handling als Zeichen interpretiert werden können. Dies heißt aber auch im Umkehrschluss, dass wir unser „a“ auch direkt über den ASCII Code erzeugen können:
Listing 20: Char Zuweisung über Zahlencode
Dieser Code liefert uns nun ebenfalls auf der Konsole das „a“.
Soweit zu den char Variablen. Jetzt mag der ein oder andere denken, dass es nicht wirklich sinnvoll ist, einen Datentyp für nur ein Zeichen zu definieren, schließlich wollen wir ja Texte verarbeiten und das sind viele zusammenhängende Zeichen. Dieses Rätsel werden wir gleich auflösen, wenn wir über „zusammengesetzte Datentypen“ sprechen. Stand jetzt können wir eben immer nur ein Zeichen speichern.
Top
Logikwerte
Bevor wir uns aber auf die zusammengesetzten Datentypen stürzen, müssen wir noch einen letzten „einfachen“ Datentypen besprechen, und zwar „boolean“. Den Namen haben diese Datentypen zu Ehren George Boole erhalten, der durch die formale Beschreibung von binärer Logik den Grundstein für unsere Computertechnik gelegt hat. Die Variablen vom Typ „boolean“ oder in manchen Programmiersprachen auch „bool“ genannt, können nur zwei Werte annehmen, nämlich „true“ oder „false“. Intern wird dies meist mit den Zahlenwerten 1 bzw. 0 abgebildet. In C und C++ (und fast allen Skriptsprachen) liegen hinter „true“ und „false“ tatsächlich die Zahlenwerte 0 und 1:
Listing 21: Analyse boolean Variablen in C
Kurze Erklärung der Codezeilen: Da bool in C ursprünglich nicht vorgesehen war, muss man stdbool.h inkludieren. In diesem File wird lediglich der „bool“ Datentyp definiert und true als 1 bzw. false als 0 definiert. Bei der Ausgabe sehen wir konsequenterweise auch 1 und 0. Interessant ist aber auch, dass eine Zuweisung von einer Zahl auf boolVar möglich ist. Jede Zahl außer 0 wird am Ende als 1 zugewiesen. Wenn wir also boolVar = -0.1; setzen, erhalten wir die Ausgabe 1.
In C# und Java ist dies wiederum nicht möglich – man hat sich hier um eine klare Trennung der Zahlendatentypen und bool (oder boolean) bemüht. In manchen Systemen, wie bspw. einigen Datenbanken, gibt es diesen Datentyp erst gar nicht, man behilft sich mit einem 8 Bit Ganzzahlentyp und definiert auch hier die „1“ als „true“ und die „0“ als „false“. Die Sinnhaftigkeit dieses Datentyps werden wir erst beim Einsatz in unseren Programmen erkennen. Bis dahin ist die einzig noch interessante Information über diesen Datentypen, dass er zwar Informationen speichert, welche „nur“ ein Bit wert sind (also 0 oder 1), aber insgesamt 8 Bit an Speicher belegt. Dies liegt an der Größe der adressierbaren Einheiten in unserem Computer. Diese ist im Regelfall 8 Bit (also 1 Byte) und demensprechend muss eine boolean Variable auch mindestens diesen Platz belegen, auch wenn sie weniger Platz benötigen würde.
Top
Überblick primitive Datentypen
Somit haben wir alle relevanten „einfachen“ – man spricht auch von „primitiven“ Datentypen besprochen. Diese Datentypen bilden den Grundstein jeder Programmiersprache. Alles andere an Datenstrukturen basiert in irgendeiner Weise auf diesen Datentypen. Hier nochmal ein kurzer Überblick mit den gebräuchlichsten Namen:
Datentyp: Bitbreite: Art der Daten: Bemerkung:
byte 8 Ganzzahlige Werte Mitunter als „signed“ und „unsigned“ deklarierbar
short 16 Ganzzahlige Werte Mitunter als „signed“ und „unsigned“ deklarierbar
int 32 Ganzzahlige Werte Mitunter als „signed“ und „unsigned“ deklarierbar
long 64 Ganzzahlige Werte Mitunter als „signed“ und „unsigned“ deklarierbar
float 32 Gleitkommazahlen Fehlerbehaftet aufgrund Umrechnung aus Basis 2
double 64 Gleitkommazahlen Fehlerbehaftet aufgrund Umrechnung aus Basis 2
char 32 Einzelnes Zeichen UTF16 ohne surrogate(13)
boolean 8 Logische Zustände Kann nur „true“ und „false“ annehmen
Tabelle 4: Wichtige primitive Datentypen
(13) Gilt für die meisten Programmiersprachen
Eine manchmal wichtige Information, gerade für die Zahlendatentypen, waren ja die Minimal- und Maximalwerte, welche bspw. in int oder long ablegbar sind. C und C++ liefern uns diese Werte in Konstanten. Den kleinsten ganzzahligen int Wert finden wir beispielsweise in der Konstante INT_MIN. Die Maximalwerte in INT_MAX bzw. für unsigned in UINT_MAX. In Java und C# gibt es für jeden primitiven Datentyp eine zugehörige Klasse, in der wir solche Informationen finden – genannt „Wrapperklasse“. In C# heißt diese Wrapperklasse genauso wie der primitive Datentyp. Java folgt seinen internen Namensregeln und dort wird sie entsprechend in Großbuchstaben benannt:
Datentyp: Wrapperklasse:
byte Byte
short Short
int Integer
long Long
boolean Boolean
char Character
Tabelle 5: Wrapperklassen in Java
Wenn wir in Java also einer long Variablen den Maximalwert zuweisen möchten, so schreiben wir:
Listing 22: Zugriff auf Wrapperklassen in Java
In C# würde dies der folgende Code erledigen:
Listing 23: Zugriff auf Wrapperklassen in C#
Die Wrapperklassen haben übrigens noch einen weiteren Daseinsgrund. In Java und C# ist alles in Klassen verpackt – auch die grundlegenden Funktionalitäten die wir bspw. für ganze Zahlen oftmals benötigen. Ein Beispiel hierfür wäre die Umwandlung eines Strings in eine int Zahl – was wir später noch ansehen werden.
Top
Umwandlungen und Typecasts
Den letzten Punkt zum Thema „primitive Datentypen“ ist der sogenannte „Typecast“. Hierunter versteht man das Übernehmen eines Wertes von Datentyp A in eine Variable des Datentyps B – also beispielsweise wollen wir den Inhalt einer „short“ Variablen in eine „int“ Variable eintragen. Hierbei gibt es nun zwei Fälle. Im ersten Fall übergeben wir die Daten von der Variablen eines Datentyps, in die Variable eines anderen Datentyps mit einem kleineren Wertebereich und im anderen Fall eben umgekehrt:
Datentransfer (Typecast) über unterschiedliche Datentypen
Abb.: 15: Datentransfer (Typecast) über unterschiedliche Datentypen
Es sollte nachvollziehbar sein, dass wir bei der Übertragung von einem „großen“ Datentyp in einen „kleinen“ Datentyp in Gefahr laufen, Informationen zu verlieren. Probieren wir es mal mit der Programmiersprache aus, die am unkritischsten mit Datentypkonversionen umgeht, nämlich C:
Listing 24: Datenverlust durch falsche Datentypen in C
Kurze Erklärung der Codezeilen: Wir erzeugen eine Variable „i“ vom Typ int, welche einen Wertebereich von -2.147.483.648 bis 2.147.483.647 hat. Der Wert 32.768 passt also dort hinein. Danach schreiben wir den Wert von dort in eine short Variable „s“, welche den Wertebereich von -32.768 bis 32.767 aufweist. Die Zahl in der int Variable „i“ passt also nicht in die short Variable.
Wenn wir das Programm ausführen, sehen wir:
auf der Konsole. Um dieses Verhalten zu verstehen, sehen wir uns einfach die Bits der beiden Zahlen an:
Bitmuster bei Übernahme der Zahl 32.768 von int nach short
Abb.: 16: Bitmuster bei Übernahme der Zahl 32.768 von int nach short
int hat eine Breite von 32 Bit, short „nur“ 16. Wenn die Bits 1:1 übernommen werden, so werden die Bits 1:1 kopiert und der linke „Überhang“ abgeschnitten. Nun ist aber im Zieldatentyp das linke Bit gesetzt, wodurch wir eine Interpretation als Zweierkomplement durchführen müssen, was entsprechend zur -32.768 führt. Wir haben also einen Datenverlust erlitten. Dieser ist aber nur dann zu beklagen, wenn der Wert in einer int Variablen nicht in die short Variable hineinpasst. Man kann es sich vorstellen, wie Wasser in einem großen ½ Liter Glas, das wir in ein kleineres ¼ Liter Glas schütten. Nun muss das große Glas aber nicht voll sein. Nur wenn der Inhalt somit im großen Glas kleiner oder gleich ¼ Liter ist, haben wir kein Problem.
Wie sieht es mit den Gleitkommazahlen in Verbindung mit ganzen Zahlen aus? Hier gibt es zwei Dimensionen der „Ungenauigkeit“, und zwar die offensichtliche Problematik, dass Nachkommastellen in ganzzahligen Datentypen nicht abgebildet werden können und die Anzahl der signifikanten Mantissenziffern. Beginnen wir mit letzterem:
Listing 25: Datenverlust durch falsche Datentypen in C
Die Ausgabe ist:
Wie die Angabe in Tabelle 3 schon andeutet, haben wir nur 7 signifikante Mantissenziffern bei der Ausgabe. Unser Ergebnis ist also nur auf die ersten 7 Stellen genau. In solchen Fällen sollten wir dann natürlich auf „double“ zurückgreifen. Wie sieht es andersherum aus? Probieren wir es aus:
Listing 26: Datenverlust durch falsche Datentypen in C
Aufgrund der Anzahl der signifikanten Stellen haben also auch hier die gleiche Ungenauigkeit wie bei Übernahme von int nach float. Analysieren wir nun mal den Umgang mit Nachkommastellen:
Listing 27: Datenverlust durch falsche Datentypen in C
Die Nachkommastellen werden also einfach „abgeschnitten“. Sie werden nicht gerundet! Das mag einem Mathematiker vielleicht jetzt verwunderlich erscheinen, aus Computersicht ist dies aber die einzig richtige Verhaltensweise. Das „Runden“ ist eine zwar nachvollziehbare aber trotzdem willkürliche Definition. Eine Kommazahl besteht nun mal aus einem ganzzahligen Teil und den Nachkommastellen…und der ganzzahlige Teil ist genau das, was wir in einer Variablen für ganze Zahlen erwarten.
Einen ganzzahligen Datentyp kennen wir noch – den Typ „char“. Auch wenn er für das Speichern von Zeichen genutzt wird; die eigentlich gespeicherten Werte sind nun mal Zahlen. Dies können wir durch einen sehr einfachen Code beweisen:
Listing 28: Datenverlust char als Zahlwert in C
Kurze Erklärung der Codezeilen: Wir erzeugen eine char Variable „c“ und schreiben ein Zeichen (in unserem Fall den Wert 'a') hinein. Dann geben wir den Variableninhalt als Zahl („%d“) und als Zeichen („%c“) aus. Danach belegen wir c mit einer int Zahl (Zahlenkonstanten sind in C immer vom Typ int) und geben sie wieder als Zahl und Zeichen aus.
Die Ausgabe lautet:
Im Prinzip ist die Erkenntnis hier die gleiche wie in Listing 20, nur dass wir jetzt wissen, was da eigentlich passiert. Das ist insofern wichtig, als dass wir bspw. jetzt auch folgenden Code interpretieren können:
Listing 29: Negative int Werte als Zahlwert in C
Kurze Erklärung der Codezeilen: Der Code soll demonstrieren, wie C mit einer Negativen Zahl umgeht. Das Einfachste wäre nun, einfach eine negative Zahl (bspw. -191) zuzuweisen. Einige C-Compiler prüfen jedoch die Zuweisung auf Plausibilität und da char Variablen keine Negativen Werte aufnehmen können, würde hier ein Compilerfehler entstehen. Insofern weisen wir einfach die 191 zu und multiplizieren den Wert in c mit der -1 und weisen das Ergebnis einfach wieder der Variablen c zu: c = c * -1;
Hier die Ausgabe:
Die Bitkombination von -191 sind in den linken 3 Bytes nur Einsen – aufgrund des Zweierkomplements. Das letzte Byte trägt die Kombination 0100.00002. char wiederum nutzt in C nur ein Byte, weshalb nur diese Bitkombination gewertet wird, was wiederum die 65 und somit das Zeichen „a“ ist.
Bitkombination von -191 als int und char (in C)
Abb.: 17: Bitkombination von -191 als int und char (in C)
Das Ganze gilt jedoch nur für C, da dort char mit einem Byte dargestellt wird. In Java oder C# werden char Daten als UTF16 mit zwei Bytes codiert. Dies ist übrigens nicht der einzige Unterschied zwischen Java/C# zu C. Insofern sehen wir uns mal grundsätzlich an, was bspw. Java mit solchen Zuweisungen macht. Beginnen wir mit der Zuweisung eines „kleinen“ Datentyps auf einen „großen“:
Listing 30: Impliziter Typecast in Java
Wie in C auch, funktioniert das problemlos. Drehen wir nun das Ganze um:
Listing 31: Fehlender expliziter Typecast in Java
Wer versucht, diesen Code zu kompilieren wird enttäuscht werden – wir haben hier einen Syntaxfehler! Java akzeptiert die Zuweisung eines „großen“ Datentypen auf einen „kleinen“ nicht, da hier Datenverlust droht. Trotzdem kann man Java dazu bewegen, diese Zuweisung doch zu akzeptieren, indem wir vor „i“ in Klammern den Zieldatentyp (in unserem Fall „short“) schreiben:
Listing 32: Expliziter Typecast in Java
Jetzt kompiliert der Code und wir bekommen auch den richtigen Wert „10“ auf der Konsole. Damit haben wir den Code für zwei wichtige Begriffe kennengelernt – impliziter und expliziter Typecast:
expliziter Typecast: Umwandlung eines Wertes von einem in einen anderen Datentyp mit einem expliziten Hinweis, dass diese Umwandlung durchgeführt werden muss. Bei den meisten Programmiersprachen wird der Zieldatentyp in Klammern vor der Quelle (in unserem Fall die umzuwandelnde Variable) geschrieben.
impliziter Typecast: Umwandlung eines Wertes von einem in einen anderen Datentyp, ohne dass im Programm diese Umwandlung angegeben wird.
Was bei einem expliziten Typecast eigentlich passiert ist, dass nur für die Dauer der Operation (in unserem Fall ist dies die Zuweisung mit „=“) der Wert umgewandelt wird. Wenn im Code also steht „short s = (short) i;“ wird der Wert aus i ausgelesen, als short uminterpretiert und dann zugewiesen.
Die Faustregel in Java und C# lautet – immer dann, wenn Datenverlust aufgrund Typumwandlungen drohen, fordert der Compiler einen expliziten Typecast ein. Grundsätzlich empfehle ich, diese Faustregel auch in C zu verwenden, obwohl sie hier nicht zwingend erforderlich ist.
Es gibt allerdings einen Fall, bei dem diese Faustregel nicht gilt. Sehen wir uns folgenden C# Code an:
Listing 33: Impliziter Typecast mit Datenverlust in C#
Hier benötigt der Compiler keinen Typecast. Der Wertebereich von float ist ja weitaus größer als der von int, allerdings nicht die Genauigkeit – sprich die Anzahl der signifikanten Stellen. Wenn wir das Programm starten, sehen wir:
Mit anderen Worten, wir haben keinen expliziten Typecast benötigt, haben aber trotzdem einen Datenverlust erlitten. Wenn wir allerdings mit den Standarddatentypen für Gleitkommazahlen (double) und Ganzzahlen (int) arbeiten, tritt dieses Problem nicht auf, da die Genauigkeit von double für int ausreichend ist.
Gehen wir nun am Schluss nochmal auf die Eigenheiten von char in C# und Java ein. In beiden Programmiersprachen haben wir einen (Zahlen-)Wertebereich bei char von 0 bis 65.535, da wir die Werte auf 16 Bit ohne Vorzeichen speichern. Insofern können wir nicht mit -191 das „A“ provozieren, sondern mit -65.471.
Listing 34: Typecast bei char in C#
Der Wert -65.471 hat als Bitmuster 1111.1111.1111.1111.0000.0000.0100.00012, wodurch die rechten 16 Bit wieder den Wert 65 für „A“ ergeben. Der letzte zu prüfende Typecast dreht sich um boolean. Da C und C++ diesen Datentypen einfach aus einer Zahl generieren dürfte klar sein, dass wir hier Zahlenwerte direkt in bool „casten“ können:
Listing 35: Typecast int nach bool in C bzw. C++
Da in C hinter den Boolean Werten Zahlen stehen dürfte es nicht wundern, dass der Typecast implizit möglich ist. Überraschend ist vielleicht, dass dies anstatt mit int auch mit double funktionieren würde, wobei dies keine praktische Relevanz hat. In C# bzw. Java ist dies aufgrund der sauberen Trennung von Zahlendatentypen und boolean nicht möglich – wir würden sowohl bei einem impliziten, als auch einem expliziten Typecast einen Compilerfehler erhalten. Wir merken uns also, dass wir nicht beliebig die Werte von einem Datentyp in den anderen umwandeln können. Es kommt zwar nicht so häufig vor, aber wenn es mal notwendig ist, sollte uns immer klar sein, was wir da eigentlich machen.
Top
CC Lizenz (BY NC SA)
CC Lizenz (BY NC SA)