Elementare Syntax von C++: Fundamentale Datentypen

Für das Erlernen der Sprache C++ ist dieser Abschnitt zentral: es werden die wichtigsten fundamentalen Datentypen ausführlich vorgestellt, also Zeichen, Boolesche Variable, ganze Zahlen und Gleitkommazahlen. Die Datentypen sind immer verknüpft mit den Operationen, die mit ihnen ausgeführt werden können. Weiter werden die Typumwandlungen (und die damit verbundenen Fehlerquellen) zwischen den fundamentalen Datentypen besprochen.

Einordnung des Artikels

In diesem Abschnitt wird die in

von einer speziellen Programmiersprache unabhängige Diskussion auf C++ angewendet. Um all die Spitzfindigkeiten zu verstehen, die einem C++-Programmierer hier Kummer bereiten können, ist es unerlässlich die Unterscheidung der fundamentalen Datentypen und die Speicherorganisation zu kennen.

Plattform-Abhängigkeit der Datentypen

Anders als in Java, sind für C++ in der Sprach-Spezifikation keine exakten Festlegungen vorgenommen, wieviel Speicherplatz von den elementaren Datentypen belegt wird. Die meisten Festlegungen erfolgen in der Form von Ungleichungen, etwa

  • Datentyp 1 belegt mindestens n Byte oder
  • Datentyp 1 belegt mindestens so viel Speicherplatz wie Datentyp 2.

Wenn in den folgenden Abschnitten entsprechende Angaben gemacht werden, beziehen sie sich auf MinGW-Compiler unter Windows 10 (64 Bit). Auf anderen Plattformen können die Angaben abweichen.

Der Datentyp void

Der Datentyp void (wörtlich: void = leer, nichtig) ist ein Grenzfall:

  • man kann kein Objekt von diesem Datentyp bilden.
  • Aber eine Funktion kann als Rückgabewert den Datentyp void besitzen: Sie führt dann lediglich Anweisungen aus, erzeugt aber keinen Rückgabewert.

Eingeführt wurde der Datentyp void im Abschnitt Prozeduren und Funktionen im Kapitel Übersicht über Programmiersprachen.

Zeichen

Eigenschaften von Zeichen

Die Datentypen, die in C++ für Zeichen zur Verfügung stehen, werden in zwei Gruppen eingeteilt:

  1. narrow character types: char , signed char , unsigned char
  2. wide character types: char16_t , char32_t , wchar_t

Weiter unten folgt ein Programm (FundTyp.cpp), das zeigt, wie man diese Datentypen näher untersuchen kann — sie können auf unterschiedlichen Rechnern andere Eigenschaften haben.

Gesetzt werden Zeichen mit der Anweisung:

char c = 'x';

Hier wurde also der — gebräuchlichste — Datentyp char verwendet; der Name der Variable ist natürlich frei wählbar. Wichtig ist, dass das Zeichen — hier x — in die sogenannten Hochkommas gesetzt wurde; falsch wäre die Verwendung von Anführungsstrichen (diese werden für string, also Zeichenketten, eingesetzt):

char c = "x";       // FALSCH! Compiler meldet Fehler

Das folgende Programm gibt aus, wieviele Zeichen ihr System unterstützt, und gibt sie danach aus:

// FundTyp.cpp
// Informationen über die fundamentalen Datentypen

#include <limits>
#include <iostream>
using namespace std;

int main(){

    int min = static_cast<int>(numeric_limits<char>::min());
    int max = static_cast<int>(numeric_limits<char>::max());
    
    cout << "Minimum: " << min << endl;      // Minimum = - 128
    cout << "Maximum: " << max << endl;      // Maximum = 127

    for (int i = min; i < max + 1; i++) {
        char c = i;
        cout << i << "\t\t" << c << endl;               // Ausgabe der Zeichen 
        cout << "sizeof c = " + sizeof c << endl;       // sizeof c = 1 (1 Byte)
    }
    
    cout << "sizeof (char) = " << sizeof (char) << endl;      // sizeof (char) = 1 (1 Byte)
}

dazu wird im Header (Zeile 4)

#include <limits>

geladen, um auf die entsprechenden Konstanten zugreifen zu können (Zeile 10 und 11):

numeric_limits<char>::min()
numeric_limits<char>::max()

Diese werden in den Zahlen min und max abgespeichert. (Den dabei eingesetzten static_cast<int> müssen Sie jetzt noch nicht verstehen.) Anschließend werden in einer for-Schleife alle Zeichen ausgegeben (Zeile 16 bis 20).

Für den ASCII-Code reichen 7 Bit (= 128 Zeichen); in C++ werden für char 8 Bit = 1 Byte bereitgestellt. Der ASCII-Code findet sich dann in den Zeichen (als Zahlen interpretiert) 0 - 127, welche Zeichen in den anderen 128 Zeichen (meist entsprechend den Zahlen von -128 bis -1) kodiert werden, hängt vom System ab — aber mit obigem Programm können Sie es herausbekommen.

Die Zeile 17 aus obigem Programm

char c = i;

ist nicht leicht zu verstehen: Wie kann man einem Zeichen eine Zahl zuordnen?

Da Zahlen und Zeichen intern identisch behandelt werden, ist diese Umwandlung möglich: auf der rechten Seite steht eine Zahl i, die zwischen min und max liegt, also wie ein Zeichen interpretiert werden kann. Und c wird gerade als dieses Zeichen gesetzt. Man nennt diesen Vorgang eine Typumwandlung (cast); manchmal muss er ausdrücklich durch den entsprechenden Befehl erzwungen werden, manchmal kann er automatisch durchgeführt werden (wie in char c = i). Mehr dazu unter Typumwandlungen.

In der for-Schleife werden dann alle erlaubten Zeichen erzeugt und auf der Konsole (zusammen mit ihrer Kodierung) ausgegeben. Die Syntax der for-Schleife wird hier noch nicht erklärt; sie sollte aber nach den Erklärungen zu den Schleifen im Pseudocode klar sein. Die Anweisung (Zeile 16)

i++     // i wird für einen Schleifendurchlauf verwendet; anschließend wird i = i + 1 gebildet

ist nur die Abkürzung für i = i + 1.

Operatoren für Zeichen

Der Operator sizeof

Obiges Programm verwendet den Operator sizeof in zwei verschiedenen Versionen:

1. Anwendung auf ein Zeichen:

In Zeile 19 (im Schleifendurchlauf) wird für jedes aktuelle Zeichen c

sizeof c

ausgegeben. Hier gibt sizeof c an, wieviel Speicherplatz (in Byte) das Zeichen c belegt.

2. Anwendung auf einen Datentyp:

Nach dem Schleifendurchlauf (Zeile 22) wird dagegen

sizeof (char)

ausgegeben. Man beachte, dass char ein Schlüsselwort ist und den Datentyp char beschreibt. Hier wird also ausgegeben, wieviel Speicherplatz für eine Variable des Datentyps char vorgesehen ist.

Der Operator sizeof ist natürlich nicht nur für Zeichen definiert, er kann auf alle elementaren Datentypen angewendet werden. Und es gibt ihn jeweils in den beiden oben besprochenen Varianten.

Aufgaben:

1. Führen Sie das Programm FundTyp.cpp aus! Dokumentieren Sie

int min static_cast<int>(numeric_limits<char>::min());
int max = static_cast<int>(numeric_limits<char>::max());
cout << "sizeof (char) = " << sizeof (char) << endl;

2. Was passiert, wenn man im Programm FundTyp.cpp die Schleife über das Maximum

int max = static_cast<int>(numeric_limits<char>::max());

hinaus weiterlaufen lässt?

Tip:

Egal mit welchem fundamentalen Datentyp Sie arbeiten, achten Sie immer darauf, dass Sie den erlaubten Bereich nicht verlassen. Man sollte nicht versuchen, Regeln aufzustellen, wie sich das Programm jenseits des Wertebereiches eines fundamentalen Datentyps verhält. (Manchmal kann man tatsächlich mit Hilfe des modulo-Operators weiterrechnen — dies ist aber sehr fehleranfällig und nicht für jeden Datentyp garantiert.)

Vergleichsoperatoren

Auf der Menge der Zeichen sind alle Vergleichsoperatoren definiert, die es später auch für Zahlen geben wird — es ist klar, dass diese Operatoren hier greifen, da die Zeichen intern wie Zahlen behandelt werden. Die möglichen Vergleichsoperatoren sind aus folgendem Programm-Schnipsel zu entnehmen:

char a = 'a';       // Nummer 97 im ASCII-Code
char z = 'z';       // Nummer 122 im ASCII-Code

a == z;      // "a gleich z": false; Vergleichs-Operator: nicht verwechseln mit dem Zuweisungsoperator =
a != z;      // "a ungleich z": true;
a < z;       // " a kleiner als z": true
a > z;       // " a größer als z": false
a <= z;      // " a kleiner oder gleich z": true
a >= z;      // " a größer oder gleich z": false

Liest man diese Liste, stellt sich sofort die Frage: was passiert bei der Ausführung von Zeile 4:

a == z;

Es wird geprüft, ob die Zeichen identisch sind (hier nicht) und dann wird das Ergebnis als Boolescher Wert (hier false) berechnet. Da der berechnete Wert hier nicht weiterverwendet wird, verschwindet er in irgendeinem Register. (Wie man mit Booleschen Variablen arbeitet, wird im nächsten Abschnitt erklärt.) Es wäre also besser gewesen zu schreiben:

cout << (a == z);

Compiliert man obiges Programm-Schnipsel (ohne die Ausgabe mit cout), erhält man eine Warnung vom Compiler:

warning: statement has no effect

Auf zwei häufige Fehler im Zusammenhang mit Vergleichsoperatoren muss hingewiesen werden:

Warnung:

1. Anstelle des Vergleichs-Operators == wird oft der Zuweisungsoperator = eingesetzt. Dies führt nicht zu einem Compiler-Fehler, aber zu merkwürdigem Verhalten des Programmes. Überlegen Sie sich daher bei jedem gleich, das Sie einsetzen wollen: Benötigt man hier den Vergleichs-Operator == oder den Zuweisungsoperator = ?

2. Bei der Verwendung der Operatoren >= , <= und != sind Fälle denkbar, in denen eine Verwechslung der Reihenfolge zu keinem Compiler-Fehler führt, aber zu einer unerwünschten Berechnung.

In der Mathematik hat es keine Vorteile, ob man eine Aussage mit dem Operator < oder mit <= formuliert — Hauptsache die Aussage ist richtig formuliert. Für die Programm-Ausführung besteht aber der Operator <= aus zwei Vergleichen (< und == ), die nacheinander berechnet werden.

Perfomance-Tip:

Versuchen Sie — vor allem in Schleifen, die oft durchlaufen werden — die Verwendung von <= und >= zu vermeiden. Das Problem lässt sich meist umformulieren, so dass < beziehungsweise > verwendet werden kann.

Aufgabe:

Überprüfen Sie, ob in dem Programm-Schnipsel mit den Vergleichsoperatoren tatsächlich die richtigen Werte angegeben sind. Beachten Sie dabei, dass anstelle von true immer 1 und anstelle von false immer 0 ausgegeben wird.

Boolesche Variable

Initialisierung

Eine Boolesche Variable ist vom Datentyp bool und kann entweder mit den Schlüsselworten true beziehungsweise false initialisiert werden oder mit den Zahlenwerten 1 beziehungsweise 0. Bei der Ausgabe einer Booleschen Variable erscheinen die Zahlen 1 beziehungsweise 0.

bool b = true;
cout << b << endl;      // b = 1
bool a = 0;
cout << a << endl;      // a = 0

Erstaunlich ist vielleicht, welchen Speicherplatz eine Boolesche Variable einnimmt:

out << "Size: " << sizeof(bool) << endl;       // Size: 1

Obwohl nur ein Bit benötigt wird, wird dennoch ein Byte reserviert. Aber da man einzelne Bits im Hauptspeicher nicht ansprechen kann, sondern immer nur Bytes, ist diese Konvention verständlich.

Damit kann auch das merkwürdige Verhalten der folgenden Zeilen erklärt werden:

bool b = 10;
cout << "b = " << b << endl;        // b = 1

Für eine Boolesche Variable wird 1 Byte Speicherplatz reserviert, aber nur wenn der Inhalt des Bytes gleich null ist (also alle Bits sind null), ist die Boolesche Variable gleich false, andernfalls ist sie true.

Operatoren für Boolesche Variablen

Für Boolesche Variablen sind die grundlegenden logischen Operatoren definiert, sie können entweder mit Schlüsselworten (not, and, or) angesprochen werden oder mit Symbolen (! , && , || ); das Ergebnis ist wieder eine Boolesche Variable:

bool a = false;
bool b = true;
bool c;

// NOT
c = not a;          // c = 1
c = !a;             // c = 0

// AND
c = a and b;        // c = 0
c = a && b;         // c = 0

// OR
c = a or b;         // c = 1
c = a || b;         // c = 1

Verwendung Boolescher Variabler

Im Programm oben wurden die drei Booleschen Variablen a, b, c deklariert und anschließend wurden die logischen Operationen ausgeführt. Oftmals ist es gar nicht nötig, die Booleschen Variablen zu deklarieren, da ein Vergleichsoperator (wie größer als, kleiner gleich und so weiter) als Ergebnis einen Wahrheitswert (true oder false — natürlich als 1 oder 0) liefert.

Das folgende Programm (genauer Programm-Schnipsel — Sie wissen inzwischen, wie man es zu einem lauffähigen Programm ausbaut) zeigt den Einsatz einer Booleschen Variable, die prüfen soll, ob ein gegebenes Zeichen eine Ziffer ist; man benötigt dazu den Code der Ziffern im ASCII-Code.

bool isNumber;      // Ziffern im ASCII-Code: 48 - 57
char c = 52;
isNumber = (c > 47) && (c < 58);
cout << "Ziffer? " << isNumber << endl;     // Ziffer? 1

Es hat sich bewährt, für Boolesche Variablen Namen zu vergeben, die für sich sprechen und mit is oder has beginnen (wie hier isNumber oder vielleicht hasSubdirectory).

Man beachte die Zeile 3:

isNumber = (c > 47) && (c < 58);

Man kann auf der rechten Seite Vergleichsoperatoren und logische Operatoren beliebig kombinieren. Die Klammersetzung sollte stets dafür sorgen, dass die Terme überschaubar bleiben. Und es ist wohl klar, dass sich hier sehr leicht Denkfehler einschleichen können. Durch den zusätzlichen Einsatz der Bedingungsprüfung (mit if) und der Alternative (mit if else) kann man den Ablauf von Programmen steuern. Dies wird später diskutiert im Kapitel Elementare Syntax von C++: Bedingung, Alternative und Mehrfachalternative.

Logisches Und, logisches Oder: short-circuit evaluation

Der Operator && besitzt folgende Eigenschaft: ist in

c = a && b;

a = 0, so ist auch c = 0, egal welchen Wert b besitzt. In C++ wird deswegen im Fall von a = 0 der Ausdruck

a && b

nicht vollständig ausgewertet, sondern es wird sofort c = 0 gesetzt.

Man nennt dieses Verfahren short-circuit evaluation.

Es wird auch beim logischen Oder || angewendet:

c = a || b;

Für a = 1 muss c = 1 sein (egal welchen Wert b hat) — auch hier wird sofort c = 1 gesetzt.

Ganzzahlige Datentypen (echte Zahlen)

Eigenschaften

Es wurde bereits gezeigt, welche Vielzahl von Datentypen in C++ für ganze Zahlen existieren, am häufigsten verwendet werden short int, int und long int.

Aufgabe:

Suchen Sie in der C++-Dokumentation nach

std::numeric_limits

und versuchen Sie ähnlich wie oben im Programm

FundTyp.cpp
#include <limits>
. . .

möglichst viele Eigenschaften der drei ganzzahligen Datentypen short int, int und long int herauszubekommen.

Dokumentieren Sie die Ergebnisse — Sie werden noch öfter darauf zugreifen müssen.

Deklaration und Initialisierung, Initialisierungsliste

Der Unterschied zwischen Deklaration und Initialisierung einer Variablen wurde bereits bei den Konsolen-Eingaben am Programm CinCout.cpp erklärt (siehe Elementare Syntax: Ein- und Ausgaben über die Konsole). Die Deklaration ist nur die Ankündigung der Variablen, damit für sie — entsprechend ihrem Datentyp — der geeignete Speicherplatz reserviert werden kann. Erst mit der Initialisierung wird der Variable ein Wert zugewiesen (im Speicherplatz abgelegt); über den Namen der Variable kann der Wert jederzeit aus dem Speicher geladen werden.

short a;                    // Deklaration der short-Variable a
cout << "a = " << a << endl;        // a unbestimmt
a = 17;                     // Initialisierung der Variable a mit dem Wert 17
short b = 18;               // Deklaration und Initialisierung

Wenn möglich, sollen Variablen sofort initialisiert werden, da nicht initialisierte Variable zu sehr merkwürdigem Verhalten führen können. Meist ist nach der Deklaration der Wert der Variable unbestimmt (indeterminate). Das heißt: Wird der Wert der Variable abgefragt, wird einfach derjenige Wert genommen, der im Augenblick der Deklaration (zufällig) im Speicher gestanden ist.

Eine generelle Regel, wie sich eine nicht-initialisierte Variable verhält, kann man nicht aufstellen: Das Verhalten ist vom Datentyp abhängig und davon, ob es eine lokale oder eine globale Variable ist (dieser Unterschied wird später noch erklärt). Daher ist eine verstreute Deklaration und Initialisierung (wie in Zeile 1 und 3 oben) nicht zu empfehlen. Besser ist die gleichzeitige Deklaration und Initialisierung (Zeile 4 oben).

Tip:

Es ist ratsam, Variable sofort zu initialisieren. Sollte es einmal nicht geschehen und der Grund dafür ist nicht offensichtlich, sollte der Grund in einem Kommentar vermerkt werden.

Ein weiterer Fehler, der bei der Initialisierung auftreten kann, ist dass der Variable ein Wert zugewiesen wird, der nicht im erlaubten Bereich des Datentyps liegt.

short a = 5;
short b = 100000;           // kein Compiler-Fehler, obwohl b außerhalb des erlaubten Bereichs für short
cout << "a + b = " << a + b << endl;        // ein "Ergebnis" wird berechnet, es ist aber nicht 100005

Sind für den Datentyp short 2 Byte vorgesehen, liegt der Wert von b außerhalb des erlaubten Bereichs (Zeile 2). Der Compiler liefert in diesem Fall nur eine Warnung (die man leicht übersehen kann), aber das Programm kann dennoch ausgeführt werden. Und es liefert ein nicht zu erwartendes Ergebnis.

Um diesen Fehler zu vermeiden, wurde eine weitere Möglichkeit der Initialisierung eingeführt (mit Hilfe von geschweiften Klammern):

short a {5};
short b {100000};       // Compiler-Fehler: 
                        // schon beim Compilieren wird überprüft, ob der Wert von b einem erlaubten short-Wert entspricht
cout << "a + b = " << a + b << endl;

Jetzt führt die Initialisierung in Zeile 2 zu einem Compiler-Fehler; das Programm kann nicht ausgeführt werden.

Diese neue Form der Initialisierung wurde mit dem Standard C++11 eingeführt und wird als Initialisierungsliste (List Initialization) bezeichnet. Der Name rührt daher, dass zuvor schon Listen initialisiert wurden, indem die Werte in geschweiften Klammern und durch Kommas getrennt angegeben wurden; eine Variable kann eben auch als eine Liste mit nur einem Eintrag aufgefasst werden.

Möglich ist auch die Schreibweise mit Gleichheitszeichen und geschweiften Klammern:

short a = {5};
short b = {100000};
cout << "a + b = " << a + b << endl;

Die Initialisierungsliste hat einen weiteren Vorteil: im folgenden Programm-Schnipsel wird eine short-Variable mit einer Gleitkommazahl initialisiert.

short pi = 3.14;                    // keine Compiler-Warnung und kein Compiler-Fehler
cout << "pi = " << pi << endl;      // pi = 3

Da pi als short definiert ist, wird sie sofort in die Zahl 3 konvertiert. Der Compiler zeigt dabei weder einen Fehler noch eine Warnung.

Dagegen führt die Verwendung der Initialisierungsliste hier zu einem Compiler-Fehler:

short pi {3.14};            // Compiler-Fehler
cout << "pi = " << pi << endl;

Tip:

Auch wenn die Symbolik nicht so suggestiv ist wie bei der Verwendung des Zuweisungsoperators, verwenden Sie für die Initialisierung stets die Initialisierungsliste.

Arithmetische Operationen

Das folgende Programm-Schnipsel zeigt, welche arithmetischen Operationen für ganze Zahlen definiert sind:

int a = 20;
int b = 3;
int c;

c = a + b;      // Addition
c = a - b;      // Subtraktion
c = a * b;      // Multiplikation
c = a / b;      // Division (mit Rest); 20:3 = 6 Rest 2; c = ganzzahliger Anteil; c = 6
c = a % b;      // modulo (Rest); c = 2

Besonders fehleranfällig ist der Einsatz der Division, da die beiden Operatoren leicht verwechselt werden können. Und wenn Gleitkommazahlen eingesetzt werden, ist die Division nicht mehr als die Division mit Rest definiert.

Aufgaben:

1. Untersuchen Sie das Verhalten bei einer Division durch 0:

  • Gibt es einen Compiler-Fehler?
  • Oder gibt es einen Laufzeit-Fehler? (Das heißt das Programm compiliert, aber erst bei der Ausführung tritt ein Fehler auf. Wenn ja: Welcher?)

2. Untersuchen Sie die Eigenschaften der Division mit Rest:

  • Wie sind ganzzahliger Anteil und Rest definiert, wenn a > 0 und b < 0 (beziehungsweise umgekehrt)?
  • Wie sind ganzzahliger Anteil und Rest definiert, wenn a < 0 und b < 0?

Zu den arithmetischen Operationen zählen noch die sogenannten Bit-Operationen, die hier noch nicht besprochen werden. Darunter fallen:

  • bitweise Negation
  • bitweises Rechtssschieben (right-shift)
  • bitweises Linksschieben (left-shift)
  • bitweises UND
  • bitweises ODER
  • bitweises XOR.

Vergleichsoperatoren

Auf ganzzahligen Datentypen sind die Vergleichsoperatoren definiert, die schon bei den Zeichen eingeführt wurden. Sie werden hier entsprechend eingesetzt und liefern wieder einen Wahrheitswert (true oder false, also 1 oder 0). Hier folgt ein Beispiel mit dem Datentyp short:

short a {5};       
short b {17};  

a == b;      // "a gleich b": false; Vergleichs-Operator: nicht verwechseln mit dem Zuweisungsoperator =
a != b;      // "a ungleich b": true;
a < b;       // " a kleiner als b": true
a > b;       // " a größer als b": false
a <= b;      // " a kleiner oder gleich b": true
a >= b;      // " a größer oder gleich b": false

Warnung:

Der Vergleichs-Operator == vergleicht bei fundamentalen Datentypen tatsächlich, ob die Speicherzellen gleichen Inhalt haben. Bei selbstdefinierten Datentypen wird dies nicht mehr richtig sein, was eine häufige Fehlerquelle ist.

Das Verhalten des Vergleichs-Operators == kann man an folgenden Beispielen untersuchen:

short a {5};
short b = a;
cout << "b == a: " << (a == b) << endl;     // true: klar, da b = a gesetzt wurde

b = 5;
cout << "b == a: " << (a == b) << endl;     // true: auch klar, da a = 5

int c = 5;
cout << "b == c: " << (c == b) << endl;     // true: obwohl b und c von unterschiedlichem Datentyp

b = 100000;         // Compiler-Warnung, da b vom Datentyp short
c = 100000;         // bei int ok!
cout << "b == c: " << (c == b) << endl;     // false: b besitzt einen unbestimmten Wert, der nicht mit 100000 übereinstimmen kann 
                                                    //denn: 100000 ist außerhalb des erlaubten Wertebereiches
                                                                    
char x = 5;                 // Vorsicht: es heißt NICHT: char x = '5';
cout << "x == a: " << (a == x) << endl;     //true: obwohl unterschiedlicher Datentyp

Typumwandlungen

In den letzten Abschnitten wurden zahlreiche Datentypen besprochen; da jeder einen bestimmten Speicherplatz beansprucht, kann es zu Problemen führen, wenn man einen Datentyp in einen anderen verwandeln möchte.

Man muss zwei Arten von Typumwandlungen (cast) unterscheiden:

  1. Explizite Typumwandlung
  2. Implizite Typumwandlung.

Bei der expliziten Typumwandlung wird ein Operator angegeben, der die Typumwandlung ausdrücklich anzeigt. Dagegen wird bei der impliziten Typumwandlung einfach ein eigentlich unpassender Datentyp eingesetzt ohne ausdrückliche Angabe eines Operators (eigentlich unpassend soll hier heißen, dass laut Sprachregeln ein anderer Datentyp erwartet wurde) — die Umwandlung wird vom Compiler vorgenommen.

Geschehen die impliziten Typumwandlungen nur zwischen elementaren Datentypen, werden sie als Standard-Konversionen bezeichnet; später wird es auch Typumwandlungen zwischen selbstdefinierten Datentypen geben.

Fehlerquelle:

Manche Typumwandlungen erfolgen fehlerfrei, bei manchen Typumwandlungen wird der Wert der Größe, die umgewandelt werden soll, verändert. Man muss mit den Eigenschaften der fundamentalen Datentypen und der Typumwandlungen gut vertraut sein, um stets zu wissen, welcher Fall vorliegt und ob man den möglichen Fehler in Kauf nehmen möchte (oder einen Sicherungsmechanismus in das Programm einbaut).

Die explizite Typumwandlung sollte mit dem Operator

t1 = static_cast<Typ1> (Typ2 t2)

erfolgen. Die Schreibweise soll andeuten, dass eine Variable t2 (oder ein Wert) vom Datentyp Typ2 gegeben ist und dass der Wert von t2 der Variable t1 (vom Datentyp Typ1) zugewiesen werden soll; wünschenswert wäre dabei, dass die Werte von t1 und t2 identisch sind und dass sich nur der Datentyp verändert.

Als Beispiel diene nachfolgendes Schnipsel, das zugleich den häufigsten Fehler bei Typumwandlungen zeigt:

int n {100000};                         // 100000 ist zu groß für short
short s = static_cast<short>(n);        // weder Compiler-Fehler noch Compiler-Warnung 
cout << "s = " << s << endl;            // s unbestimmt

Der Speicherplatz einer short-Variable reicht nicht aus, um die Zahl 100000 aufzunehmen, daher ist der Wert von s unbestimmt.

Man kann obige Typumwandlung auch folgendermaßen durchführen — diese Schreibweise sollte aber nicht mehr verwendet werden, da sie bei verschachtelten Ausdrücken leicht zu übersehen ist:

short s = (int) n;                  // weder Compiler-Fehler noch Compiler-Warnung 
cout << "s = " << s << endl;        // s unbestimmt

Es ist auch möglich, die Typumwandlung ohne static_cast -Operator auszuführen, wie folgendes Beispiel zeigt:

int n {100000};         // 100000 ist zu groß für short
short s {n};            // Compiler-Warnung: warning narrowing conversion of 'n' from 'int' to 'short int'
cout << "s = " << s << endl;        // s unbestimmt

Die Verwendung der Initialisierungsliste zur Initialisierung von s sorgt für eine Compiler-Warnung; gleichwertig — bis auf die Compiler-Warnung — ist auch:

int n {100000};          // 100000 ist zu groß für short
short s = n;             // keine Compiler-Warnung
cout << "s = " << s << endl;         // s unbestimmt

Die Umwandlung in den short-Wert erfolgt in beiden Fällen automatisch, also ohne den Aufruf des static_cast -Operators. Man nennt dies eine implizite Typumwandlung.

Die implizite Typumwandlung birgt daher dieselbe Fehlerquelle wie die explizite Typumwandlung: man kann unbestimmte Werte erzeugen. Der Vorteil des static_cast -Operators liegt lediglich darin, dass die Typumwandlung deutlich im Quelltext erkennbar ist.

Es stellt sich natürlich die Frage: wann liefert eine implizite Typumwandlung keinen Fehler?

Es gibt zwei Fälle:

1. Bei einer sogenannten Aufweitung wird der gegebene Wert in einem Datentyp mit größerem Speicherplatz abgelegt — hierbei kann kein Fehler auftreten; das folgende Beispiel zeigt dies:

short n {5};
int m = n;      // m == 5, ist jetzt aber vom Datentyp int

Für jeden gültigen short-Wert wird automatisch ein gültiger int-Wert erzeugt.

2. Manchmal bei einer Eingrenzung (narrowing conversion), nämlich nur in dem Fall, wenn die umzuwandelnde Zahl in den engeren Wertebereich passt:

int i {1000};
short s {i};        // s == 1000

Hier führt die implizite Typumwandlung zu keinem Fehler. Das Problem ist natürlich, dass mit Variablen gearbeitet wird, deren Werte sich bei der Programm-Ausführung verändern und die beim Programmieren noch nicht bekannt sind.

Abbildung 1: Typumwandlungen zwischen ganzzahligen Datentypen.Abbildung 1: Typumwandlungen zwischen ganzzahligen Datentypen.

In Abbildung 1 sind die Typumwandlungen zwischen ganzzahligen Datentypen graphisch dargestellt:

  • Bei einer Aufweitung (promotion, grün) wird der Zahlenwert unverändert übernommen, aber der Datentyp wird verändert; man muss keinen ausdrücklichen Operator angeben (nur zur besseren Lesbarkeit des Quelltextes) und es kann kein Fehler entstehen.
  • Bei anderen Standard-Konversionen (dunkelrot) kann es dagegen zu einem unbestimmten Zahlenwert kommen; dies kann auch nicht durch den Einsatz eines cast-Operators vermieden werden.

Die unterschiedliche Größe der dargestellten Datentypen soll die Größe der entsprechenden Speicherzellen andeuten; der Versatz soll anzeigen, dass bei mit Vorzeichen behafteten Datentypen (signed) der Wertebereich (nahezu) symmetrisch um die Null liegt; bei den anderen Datentypen (unsigned) ist stets null der minimale Wert.

Aufgaben:

1. Untersuchen Sie die Umwandlungen zwischen

  • char und short
  • bool und short.

Bei welchen impliziten Typumwandlungen können Fehler auftreten, bei welchen nicht?

2. Welchen Nutzen hat folgender Code:

int n = 1;
// do something with n
cout << ( n == static_cast<int> ( static_cast<short> (n) ) ) << endl;

Gleitkommazahlen

In C++ gibt es drei Arten von Gleitkommazahlen (floating point numbers): float, double und long double; die Bezeichnung float erklärt sich von alleine, double soll andeuten, dass die Genauigkeit gegebenüber float meist verdoppelt ist (double precision) und long double ist wie bei den ganzzahligen Datentypen zu verstehen (die Genauigkeit ist nochmals erhöht). Die exakte Größe der entsprechenden Speicherzellen ist in der Spezifikation wieder nicht vorgegeben und plattformabhängig. Allerdings ist der Zahlenbereich von double stets so groß, dass man long double nur in exotischen Anwendungen benötigt.

Initialisierung

Was oben über die Initialisierungslisten gesagt wurde, gilt natürlich auch für die Gleitkommazahlen (und alle anderen elementaren Datentypen).

Wie bereits besprochen, werden Gleitkommazahlen intern durch

  • Vorzeichen der Mantisse,
  • Mantisse,
  • Vorzeichen des Exponenten und
  • Exponent

dargestellt. Dies heißt aber nicht, dass sie nur in dieser Form initialisiert werden müssen, möglich sind auch folgende Schreibweisen:

float a {1};
float b {1.0};
float c {1.444449};
float d {-1.0e12};
float e {1.444449e-12};
float f {1.444449E-12};

cout << a << endl;
cout << b << endl;
cout << c << endl;
cout << d << endl;
cout << e << endl;
cout << e * e << endl;
cout << e * e * e * e << endl;
cout << f << endl;

Die Ausgaben lauten:

1
1
1.44445             // c != 1.444449
-1e+012             // d == - 1 * 10^12
1.44445e-012        // e != 1.444449e-12, wieder der Rundungsfehler
2.08643e-024        // e^2: der Fehler pflanzt sich fort
0                   // e^4: was ist hier passiert?
1.44445e-012

Zeile 1: a wird wie eine ganze Zahl eingegeben, intern aber wie eine Gleitkommazahl (hier float) behandelt.

Zeile 2: b wird ausdrücklich mit einer Nachkommastelle angegeben, obwohl b == 1 ; diese Angabe ist zwar überflüssig, aber empfehlenswert, da der Leser des Programmes sofort sieht, dass es sich um eine Gleitkommazahl handelt. Das Komma muss als Dezimalpunkt geschrieben werden.

Zeile 3: Endlich eine echte Gleitkommazahl.

Wird die Zahl später ausgegeben, wird sie verändert: Anstelle von 1.444449 erscheint 1.44445.

Dies liegt daran, dass standardmäßig die Ausgabe mit 5 Nachkommastellen erfolgt; setzt man diese Genauigkeit hoch, wird die eingegebene Zahl auch richtig ausgegeben.

Aber dennoch hier wird ein Problem sichtbar: Irgendwann ist die Anzahl der Nachkommastellen so groß, dass eine Zahl nicht in der gewünschten Genauigkeit abgespeichert werden kann. Die Mantisse muss zum Abspeichern verändert werden. Da dies in den Dualzahlen passiert, ist es im Allgemeinen schwer vorherzusagen, wie groß der Genauigkeitsverlust ist. (Mehr dazu später unter den Typumwandlungen).

Zeile 4 und 5: Gleitkommazahlen können wie im Taschenrechner in der Zehnerpotenzschreibweise eingegeben werden (dazu wird das kleine e verwendet).

Zeile 6: Anstelle des kleinen e kann auch das große E verwendet werden (bei der Ausgabe erscheint immer e).

Zeile 13: Bei der Berechnung von e2 pflanzt sich der Genauigkeitsverlust weiter fort.

Zeile 14: Das Ergebnis ist eigentlich: e4 ≈ 4.3532 * 10-48. Aber jetzt ist der Exponent so klein, dass er im Datentyp float nicht mehr dargestellt werden kann, daher wird e4 zu 0 abgerundet.

Aufgabe:

Suchen Sie in der C++-Dokumentation nach

std::numeric_limits

und versuchen Sie ähnlich wie oben im Programm

FundTyp.cpp
#include <limits>
. . .

möglichst viele Eigenschaften der Gleitkommazahl-Datentypen float und double herauszubekommen.

Dokumentieren Sie die Ergebnisse — Sie werden noch öfter darauf zugreifen müssen.

Typumwandlungen

Aufweitung und Eingrenzung

Was oben bei den Typumwandlungen zwischen ganzzahligen Datentypen gesagt wurde, bleibt für Gleitkommazahlen gültig:

  • Eine Aufweitung (promotion) erfolgt fehlerfrei, nur der Datentyp wird geändert; die Typumwandlung kann als implizite Konversion vorgenommen werden (es ist kein static_cast -Operator nötig).
  • Eine Eingrenzung (narrowing conversion) dagegen kann zu einem unbestimmten Zahlenwert führen; dies kann auch der static_cast -Operator nicht vermeiden. Man muss hier die Grenzen der Zahlenbereiche kennen, um keinen Fehler zu machen.

Beispiele:

// Aufweitung:
float f {1.5e20};

double d = static_cast<double> (f);     // mit static_cast
cout << "d = " << d <<  endl;           // 1.5e+020

d = f;                                  // ohne static_cast
cout << "d = " << d <<  endl;           // 1.5e+020

// Eingrenzung:
d = 1.5e200;

f = static_cast<float> (d);         // mit static_cast
cout << "f = " << f <<  endl;       // 1.#INF

f = d;                              // ohne static_cast
cout << "f = " << f <<  endl;       // 1.#INF

Der Überlauf, nämlich dass eine zu große Zahl im Datentyp float abgespeichert wird (Zeile 13 und 16), wird angezeigt durch:

1.#INF

Genauigkeitsverlust

Eine weitere Fehlerquelle ist der sogenannte Genauigkeitsverlust. Im folgendem Beispiel wird ein Bruch 2/3 berechnet, aber das Ergebnis einmal als float und einmal als double abgespeichert (Zeile 4 und 5). Da beide Datentypen eine unterschiedliche Länge der Mantisse besitzen, wird die Zahl 2/3 mit anderer Genauigkeit gespeichert. Die Folge ist, dass die Differenz 2/3 - 2/3 ≠ 0 (Zeile 7):

float z {2};         // Zähler
float n {3};         // Nenner

float b1 = z / n;
double b2 = z / n;

double diff = b2 - b1;
cout << "diff = " << diff << endl;          // diff = -1.98682e-008

Dieser Genauigkeitsverlust scheint harmlos zu sein: am obigen Beispiel sieht man, dass die Differenz sehr klein ist im Vergleich zu 2/3. Bedenkt man aber, dass in der Mathematik zahlreiche Kriterien formuliert werden mit Aussagen wie:

Falls eine Größe D = 0 ist, gilt ... , falls die Größe D ≠ 0, gilt ...

Wie kann man ein derartiges Kriterium in ein Programm umsetzen?

Aufgaben:

1. Wo in der Mathematik werden oben genannte Kriterien verwendet?

Nennen Sie einige Beispiele!

Welche praktische Bedeutung haben sie?

2. Warum tritt der Effekt des Genauigkeitsverlust im Beispiel oben nicht auf, wenn man 1/2 - 1/2 berechnet?

Der oben beschriebene Genauigkeitsverlust tritt natürlich bei jeder Eingrenzung auf, wenn im engeren Datentyp nicht genügend Stellen für die Nachkommastellen zur Verfügung stehen, siehe folgendes Beispiel:

// Genauigkeitsverlust bei Eingrenzung
double x {1.444444449};
float y {x};

// Ausgabe ohne setprecision, default-Wert ist 5:
cout << "double: x = " << x <<  endl;
cout << "float: y = " << y <<   endl;
cout << "double mit setprecision(10): x = " << setprecision(10) << x << endl;
cout << "float mit setprecision(10): y = " << y <<  endl;
double: x = 1.44444
float: y = 1.44444
double mit setprecision(10): x = 1.444444449
float mit setprecision(10): y = 1.444444418

Umgekehrt kann daher auch die Anzahl der Nachkommastellen ein Grund für die Verwendung eines bestimmten Datentyps sein (und nicht nur der größere Wertebereich).

Aufgabe:

Um die am häufigsten benötigten Gleitkomma-Zahlenbereiche float und double richtig einzusetzen, benötigt man die Wertebereiche (wurden in einer Aufgabe oben bereits bestimmt) und die Anzahl der Nachkommastellen, die richtig angegeben werden. Dokumentieren Sie beide Größen.

Informationsverlust

Ein ähnlicher Effekt wie der Genauigkeitsverlust ist der Informationsverlust: Er betrifft die Umwandlung einer Gleitkommazahl in einen ganzzahligen Datentyp, wie im folgenden Beispiel:

double x = 1.5;
short n = static_cast<short> (x);       // n == 1

Da man in short keine Nachkommastellen abspeichern kann, werden sie einfach abgeschnitten (und damit 1.5 in 1 verwandelt). Der static_cast -Operator kann den Informationsverlust nicht vermeiden (bei einer impliziten Konversion wäre der Fehler ebenso aufgetreten).

static_cast

Ganzzahlige Division und

Bei den bisherigen Typumwandlungen war der static_cast -Operator nicht nötig (er sorgt lediglich für mehr Klarheit im Quelltext), man kann alle Typumwandlungen auch mit impliziten Konversionen durchführen. Das folgende Beispiel zeigt, dass es tatsächlich Fälle gibt, bei denen man auf den static_cast -Operator nicht verzichten darf:

short z {3};            // Zähler
short n {4};            // Nenner
float b1 =  static_cast<float>(z) / n;          // b1 == 0.75
float b2 = z / n;                               // b2 == 0 (ganzzahlige Division 3:4 = 0 Rest 3)
float b3 = static_cast<float>( z / n);          // b3 == 0; Der cast kommt zu spät!

Zähler und Nenner eines Bruches sind als short gegeben. Werden sie dividiert wie in Zeile 4, wird die Division als ganzzahlige Division durchgeführt und liefert das Ergebnis 0. Dass das Ergebnis als float deklariert wurde, verhindert den Fehler nicht.

Es nützt auch nichts, das Ergebnis der Division mit dem static_cast -Operator in float zu verwandeln (Zeile 5): der cast kommt zu spät!

Richtig ist, den Zähler (oder den Nenner) in float umzuwandeln und dann erst zu dividieren (Zeile 3). Hier kann man auf den static_cast -Operator nicht verzichten.

Mathematische Funktionen

Die Standard-Bibliothek bietet eine Vielzahl von mathematischen Funktionen — die Dokumentation sollten Sie bei Bedarf konsultieren (nur die wenigsten davon werden Sie selber implementieren können).

C++ reference -> Numerics library -> Common mathematical functions

Die wichtigsten Funktionen sind

  • Wurzelfunktion
  • trigonometrische Funktionen
  • Exponential- und Logarithmusfunktion
  • Betragsfunktion
  • mehrere Varianten der Division
  • Funktionen zum Runden von Zahlen nach unterschiedlichen Rundungs-Strategien.

Für die meisten dieser Funktionen muss cmath inkludiert werden (genaueres entnehmen Sie der Dokumentation):

#include <cmath>

Aufgabe:

Berechnen Sie für x = 0, x = ± 0.5 und x = ± 1 folgende Funktionen:

1/x, 1/x2, √x, √(1 - x2), sin x, cos x, tan x, arcsin x, arccos x, exp(x), exp(- x2), ln x, log x.

(die letzten beiden Funktionen sind der natürliche Logarithmus und der Logarithmus zur Basis 10).

Bei welchen Funktionen liegen die gegebenen x-Werte nicht im Definitionsbereich? Wie reagiert die Programm-Ausführung darauf?

Besonderheiten

Konstanten: Das Schlüsselwort const

Es gibt die Möglichkeit, Größen, deren Wert nicht veränderlich sein soll, durch das Schlüsselwort const zu kennzeichnen. Der Compiler erkennt, ob eine Anweisung versucht, eine als Konstante deklarierte Variable zu verändern und liefert eine Fehlermeldung, siehe folgendes Programm-Schnipsel:

const float PI = 3.14;
// Bessere Näherung:
PI = PI + 0.00159;              // Compiler-Fehler: assignment of read-only variable 'PI'
const float SPEED_OF_LIGHT = 3.0e8;       // Lichtgeschwindigkeit in m/s

Konstanten können zusätzlich leichter erkennbar gemacht werden, indem sie nur mit Großbuchstaben geschrieben werden. Da dann aber die CamelCase-Schreibweise nicht mehr anwendbar ist, werden Unterstriche eingesetzt (wie in SPEED_OF_LIGHT ).

Hexadezimal-, Oktal- und Dualzahlen und weitere Formatierungshilfen

Zahlen (von beliebigem Datentyp) können auch als Hexadezimal-, Oktal- oder Dualzahlen eingegeben werden; kenntlich gemacht werden sie durch ein führendes 0x oder 0X (für Hexadezimalzahlen), durch ein führendes 0 (für Oktalzahlen) beziehungsweise ein führendes 0b oder 0B (Dualzahlen):

// Hexadezimalzahlen
int h {0xFFFF};         // 16^4 -1 = 65 535
cout << "h = " << h << endl;

// Oktalzahlen
int o {0777};           // 8^3 - 1 = 511
cout << "o = " << o << endl;

// Dualzahlen
int d {0b1111'1111};            // 2^8 -1 = 255
cout << "d = " << d << endl;

führt zur Ausgabe:

h = 65535
o = 511
d = 255

Damit die Zahlen leichter lesbar sind, kann man (wie oben bei 0b1111'1111 ) Hochkommas als Trennungszeichen einfügen; allerdings werden diese nicht von allen Compilern als Trennungszeichen erkannt.

Die Möglichkeit Dualzahlen einzugeben, wurde erst mit der Spezifikation C++14 eingeführt (ihr Compiler benötigt die zugehörige Option).

Für die Ausgabe von gibt es vorbereitete Funktionen, wie die folgende Anwendung zeigt, die die drei oben definierten Variablen h, d, o als Hexadezimalzahl ausgibt:

cout << "hex: " << endl;
cout << hex << h << endl;
cout << hex << d << endl;
cout << hex << o << endl;

Die Ausgabe lautet:

hex:
ffff
ff
1ff

Für Oktalzahlen wird anstelle von hex eingesetzt: oct .

Die Basis kann auch ausdrücklich gesetzt werden:

#include <iomanip>

// Basis ausdrücklich setzen (erfordert #include <iomanip>); leider nur Basis 8, 10, 16 möglich
cout << setbase(16) << 10 << endl;       // Ausgabe: a
                                         // andere Basen werden auf 10 abgebildet
                                        
cout << 10 << endl;                      // Ausgabe: a  Denn: Solange die Basis nicht umgestellt wird, bleibt sie 16

Aufgaben:

1. Testen Sie, ob die hier vorgestellten Funktionen mit Gleitkommazahlen arbeiten.

2. Es gibt noch sehr viele weitere Funktionen, die den Input- oder Output-Stream cin oder cout beeinflussen. Diese sind in Input/output manipulators enthalten und werden (wie im Beispiel oben) mit

#include <iomanip>

inkludiert.

Suchen Sie die entsprechenden Seiten in der Dokumentation auf und testen Sie insbesondere die Eigenschaften von

boolalpha
showbase
setbase
setfill
setprecision
fixed
scientific
hexfloat
Alle Kommentare
Durch die Nutzung dieser Website erklären Sie sich mit der Verwendung von Cookies einverstanden. Außerdem werden teilweise auch Cookies von Diensten Dritter gesetzt. Genauere Informationen finden Sie in unserer Datenschutzerklärung sowie im Impressum.