Implizite Typkonvertierungen sind eine der Ursachen für undefiniertes Verhalten in C- und C++-Programmen. In Kurzform bedeutet dies, dass solche Programme alles Mögliche tun dürfen und unvorhersehbar reagieren. C++-Routinier Rainer Grimm zeigt, wie C++-Entwickler diese Falle umgehen.
Undefiniertes Verhalten heißt für ein Programm, dass es entweder das Richtige tut oder ein vermeintlich falsches Ergebnis liefert oder abstürzt. Kurzum: Alles ist drin. Die C++-Community spricht auch von Catch-Fire-Semantik, sieht also den Rechner sprichwörtlich in Flammen aufgehen. Grund genug, sich mit impliziten Typkonvertierungen zu beschäftigen.
Das große Bild
Im vorigen Artikel [1] lieferte ich zehn Tipps, die guten C++-Code auszeichnen, Abbildung 1 listet sie noch einmal auf. Der aktuelle Artikel stellt die Initialisierung mit geschweiften Klammern, das neue Null-Zeiger-Literal »nullptr« und die Aufzählungen mit Gültigkeitsbereich in den Fokus. Gemeinsam ist allen dreien, dass sie implizite Typkonvertierung in modernem C++ verbieten. Der Artikel zeigt, wie sich das im Detail äußert.
Keine Engpässe
Verengung – oder genau gesagt verengende Konvertierung (Narrowing Conversion) – bezeichnet eine implizite Konvertierung arithmetischer Typen unter Verlust der Datengenauigkeit. Das hört sich erst mal gar nicht gut an. Das kleine Beispiel in Listing 1 stellt die Problematik mit klassischer Initialisierung für Built-in-Datentypen nach. Dabei ist es unerheblich, ob der Code die Variable direkt oder durch Zuweisung initialisiert.
Listing 1
Verengung durch klassische Initialisierung
01 #include <iostream>
02
03 int main(){
04
05 char c1(999);
06 char c2= 999;
07 std::cout << "c1: " << c1 << std::endl;
08 std::cout << "c2: " << c2 << std::endl;
09
10 int i1(3.14);
11 int i2= 3.14;
12 std::cout << "i1: " << i1 << std::endl;
13 std::cout << "i2: " << i2 << std::endl;
14
15 }
Die Ausgabe des Programms in Abbildung 2 bringt zunächst zwei Erkenntnisgewinne. Der erste: Das Literal »999« passt nicht in den Datentyp »char«. Der zweite: Der »int«-Datentyp schneidet das Literal »3.14« ab. Greift der Entwickler beim Initialisieren hingegen zu geschweiften Klammern (»{}«), sieht das Ergebnis wie in Listing 2 und damit bereits ein wenig anders aus.
Listing 2
Verengung verhindern mit moderner Initialisierung
01 #include <iostream>
02
03 int main(){
04
05 char c1{999};
06 char c2= {999};
07 std::cout << "c1: " << c1 << std::endl;
08 std::cout << "c2: " << c2 << std::endl;
09
10 int i1{3.14};
11 int i2= {3.14};
12 std::cout << "i1: " << i1 << std::endl;
13 std::cout << "i2: " << i2 << std::endl;
14
15 }
Das Programm ist fehlgeformt (ill-formed) und damit syntaktisch falsch. Der Compiler sollte beim Verengen mit einer »{}«-Initialisierung eine Warnung ausgeben, muss sie aber nicht ablehnen. Tatsächlich ist allen modernen Compilern wie GCC [2], Clang [3] oder Microsofts Visual Compiler [4] die verengende Konvertierung eine Fehlermeldung wert. Nur dem GCC 4.8 (Abbildung 3) muss der Entwickler den Fehler über ein Flag »-Werror=narrowing« näherbringen.

Abbildung 2: Beim impliziten Konvertieren mit klassischer Initialisierung kommt es zu Problemen mit den Literalen.
Doch es gibt Ausnahmen. Ein Ausdruck wie »char c3{8}« ist keine Verengung, da die Zahl 8 in den Datentyp »char« passt. Das gilt auch für »char c3= {8}«.
Keine Nullnummer
Das neue Null-Zeiger-Literal »nullptr« räumt mit der Mehrdeutigkeit der Zahl Null und dem Makro »NULL« in C++ auf. Um die Vorzüge des noch jugendlichen Literals wertzuschätzen, hilft es, die Uhr in die Zeiten von Null oder »NULL« als Nullzeiger-Literal zurückzudrehen.
Null Fehler
Das Problem mit dem Literal Null ist, dass es, abhängig vom Kontext den Null-Zeiger »(void*)0« oder die Zahl Null bezeichnet. Zugegeben, an diese Schrägheit haben sich die meisten C++-Entwickler fast gewöhnt. Aber nur fast: Listing 3 birgt rund um die Zahl Null doch noch einiges an Verwirrpotenzial.
Listing 3
Die Null als Null-Zeiger-Literal und Ganzzahl
01 #include <iostream>
02 #include <typeinfo>
03
04 int main(){
05
06 std::cout << std::endl;
07
08 int a= 0;
09 int* b= 0;
10 auto c= 0;
11 std::cout << typeid(c).name() << std::endl;
12
13 auto res= a+b+c;
14 std::cout << "res: " << res << std::endl;
15 std::cout << typeid(res).name() << std::endl;
16
17 std::cout << std::endl;
18
19 }
Die Frage zum Listing lautet: Von welchem Datentyp sind die Variable »c« in der Zeile 10 und die Variable »res« in der Zeile 13? Wer das Programm ausführt, sieht die Antwort (Abbildung 4). Die Variable »c« ist vom Typ »int«, die Variable »res« ist vom Typ Zeiger auf »int: int*«. Eigentlich ganz einfach, denn in dem Ausdruck »a+b+c« (Zeile 13) findet Zeigerarithmetik statt.
Ist das Makro »NULL« nun die Rettung? Fast jeder ahnt es – eher nicht.
Das Makro “NULL”
Das Problem mit der Null-Zeiger-Konstanten »NULL« besteht darin, dass C++ sie implizit nach »int« konvertieren kann. Das ist auch nicht gerade schön. Ein Blick in die CPP-Referenz [5] zeigt, dass es mehrere Möglichkeiten gibt, das Makro »NULL« konkret zu implementieren. Eine sieht so aus:
#define NULL 0 //since C++11 #define NULL nullpt
Diesen Umstand scheint der GCC 4.8 allerdings nicht einzusehen, denn er betrachtet »NULL« in diesem Fall als vom Typ »long int«. Dazu gleich mehr. Der Umgang mit dem Makro in Listing 4 wirft einige Fragen auf.
Listing 4
Das NULL-Makro
01 #include <iostream>
02 #include <typeinfo>
03
04 std::string overloadTest(int){
05 return "int";
06 }
07
08 std::string overloadTest(long int){
09 return "long int";
10 }
11
12 int main(){
13
14 std::cout << std::endl;
15
16 int a= NULL;
17 int* b= NULL;
18 auto c= NULL;
19 // std::cout << typeid(c).name() << std::endl;
20 // std::cout << typeid(NULL).name() << std::endl;
21
22 std::cout << "overloadTest(NULL)= " << overloadTest(NULL) << std::endl;
23
24 std::cout << std::endl;
25
26 }
Der Compiler moniert zunächst die implizite Konvertierung nach »int« in Zeile 16 (Abbildung 5). Das ist nachvollziehbar. Deutlich mehr verwirrt jedoch die Warnung in Zeile 18. Mit Hilfe einer automatischen Typableitung ermittelt der Compiler den Typ »long int« für die Variable »c«. Gleichzeitig stellt er aber fest, dass er ihn in den Ausdruck »NULL« konvertieren muss. Diese Beobachtung deckt sich auch mit dem Aufruf der Funktion »overloadTest(NULL)« in Zeile 22.
In diesem Fall wählt der Compiler die Version in Zeile 8 für den Typ »long int« aus. Auf Plattformen, auf denen »NULL« vom Typ »int« ist, ruft der Compiler für den Parametertyp »int« in Zeile 4 »overloadTest()« auf. Alles konform zum C++-Standard, Abbildung 5 liefert die harten Fakten.
Bleibt das Rätsel, welcher konkrete Typ sich hinter der Null-Zeiger-Konstanten »NULL« verbirgt. Dies löst der neugierige Entwickler, indem er die Zeilen 19 und 20 in Listing 4 auskommentiert, das Programm übersetzt und einen Blick auf Abbildung 6 wirft. Für den Compiler ist »NULL« einerseits eine Konstante vom Typ »long int«, andererseits aber eine Zeiger-Konstante. Das erklärt die Warnungen in Abbildung 5 beim Übersetzen des Codes.
Wenn dieser Abschnitt aber eines zeigt, dann vor allem, dass Entwickler besser die Finger vom Makro »NULL« lassen sollten. Im modernen C++ naht auch bereits die Rettung in Form des Null-Zeiger-Literals »nullptr«.
Zeiger auf die Null
Mit der Mehrdeutigkeit der Zahl Null und des Makros »NULL« räumt »nullptr« auf. Das Literal ist und bleibt eine Null-Zeiger-Konstante vom Typ »std::nullptr_t« und lässt sich Zeigern beliebigen Typs zuweisen. Das macht den Zeiger zu einem Null-Zeiger, der auf kein Datum verweist. Einen solchen Null-Zeiger kann der Entwickler nicht dereferenzieren. Zeiger dieses Typs lassen sich sowohl mit allen Zeigern sowie mit Zeigern auf Klassenmitglieder vergleichen als auch implizit in alle Zeiger und Zeiger auf Klassenmitglieder konvertieren.
Nicht möglich ist es, Zeiger dieses Typs implizit in integrale Typen zu konvertieren und mit ihnen zu vergleichen – »bool« bildet allerdings eine Ausnahme. Entwickler können »nullptr« implizit auch in einen Wahrheitswert verwandeln und in logischen Ausdrücken verwenden (Listing 5).
Listing 5
Das Null-Zeiger-Literal nullptr
01 #include <iostream>
02 #include <string>
03
04 std::string overloadTest(char*){
05 return "char*";
06 }
07
08 std::string overloadTest(long int){
09 return "long int";
10 }
11
12 int main(){
13
14 std::cout << std::endl;
15
16 long int* pi = nullptr;
17 // long int i= nullptr; // ERROR
18 auto nullp= nullptr; // type std::nullptr_t
19
20 bool b = nullptr;
21 std::cout << std::boolalpha << "b: " << b << std::endl;
22 auto val= 5;
23 if ( nullptr < &val ){ std::cout << "nullptr < &val" << std::endl; }
24
25 // calls char*
26 std::cout << "overloadTest(nullptr)= " << overloadTest(nullptr)<< std::endl;
27
28 std::cout << std::endl;
29
30 }
Mit dem Null-Zeiger-Literal »nullptr« initialisiert der Entwickler zwar einen Zeiger vom Typ »long int« (Zeile 16), doch kann er ihn nicht einfach automatisch in einen »long int«-Typ konvertieren (Zeile 17). Interessant ist auch die automatische Typableitung in Zeile 18. Sie macht »nullp« zum Wert vom Typ »std::nullptr _t«. Die Null-Zeiger-Konstante verhält sich wie ein Wahrheitswert, den das Programm mit »false« initialisiert hat. Das zeigen die Zeilen 20 bis 23.
Hat »nullptr« die Wahl zwischen einem »long int« und einem Zeiger, entscheidet er sich aber für den Zeiger (Zeile 26). Die Ausgabe des Ganzen zeigt Abbildung 7.
Die einfache Regel lautet also: »nullptr« anstelle von Null oder »NULL« verwenden. Wen das noch nicht überzeugt, der sollte einen Blick auf das stärkste Geschütz im Arsenal riskieren.
Generischer Code
Die Literale Null und »NULL« offenbaren in generischem Code ihre wahre Natur. Dank der Template-Argument-Ableitung stehen sie im Körper des Funktions-Template nur noch als integrale Typen zur Verfügung. Kein Hinweis bleibt auf ihre Ursprünge als Null-Zeiger-Konstanten (Listing 6).
Listing 6
Das Null-Zeiger-Literal, Null und NULL in generischem Code
01 #include <cstddef>
02 #include <iostream>
03
04 template<class P >
05 void functionTemplate(P p){
06 int* a= p;
07 }
08
09 int main(){
10 int* a= 0;
11 int* b= NULL;
12 int* c= nullptr;
13
14 functionTemplate(0);
15 functionTemplate(NULL);
16 functionTemplate(nullptr);
17 }
Zwar könnten Entwickler Null und »NULL« dazu verwenden, die »int«-Zeiger in den Zeilen 10 und 11 zu initialisieren. Setzen sie die Werte aber als Argumente für das Funktionstemplate ein, quittiert der Compiler das mit einer sehr deutlichen Fehlermeldung. Für ihn ist der Typ von Null (Zeile 6) im Funktionstemplate »int« der Typ von »NULL long int«. Im Vergleich dazu verhält sich der »nullptr« aber deutlich berechenbarer. Sowohl in der Zeile 12 als auch in der Zeile 6 ist er vom Typ »std::nullptr_t« (Abbildung 8).
Das ist aber noch nicht die ganze Geschichte dazu, wie modernes C++ undefiniertes Verhalten in klassischem C++ auflöst.
Streng typisierte Aufzählungstypen
Aufzählungen (»enum«) sind eine beliebte Art, sprechende Integer-Konstanten (Aufzähler) zu definieren. Leider besitzen sie in klassischem C++ einige Nachteile, genau genommen drei:
- Sie konvertieren implizit zu »int«.
- Sie führen ihren Bezeichner in den umgebenden Bereich ein.
- Der zugrunde liegende Typ lässt sich nicht angeben.
Zuerst zum letzten Punkt. Entwickler können den zugrunde liegenden Typ der Aufzählung nicht angeben. Daher lassen sich Aufzählungen nicht vorwärts deklarieren. Dieser Typ muss in klassischem C++ nur groß genug sein, um alle Aufzähler darzustellen.

Abbildung 9: In Listing 7 konvertiert die Variable »red« implizit nach »int«.
Noch größeres Überraschungspotenzial bergen die beiden ersten Punkte, wie Listing 7 zeigt. Einerseits sind die Aufzähler »red«, »green« und »blue« im umgebenden Bereich bekannt. Das macht in Zeile 17 die Definition der Variablen »red« unmöglich. Andererseits konvertiert »red« implizit nach »int« wie in Zeile 13. Abbildung 9 zeigt das Programm in der Anwendung.
Listing 7
Klassische Aufzählungstypen
01 #include <iostream>
02
03 int main(){
04
05 std::cout << std::endl;
06
07 enum Colour{red= 0,green= 2,blue};
08
09 std::cout << "red: " << red << std::endl;
10 std::cout << "green: " << green << std::endl;
11 std::cout << "blue: " << blue << std::endl;
12
13 int red2= red;
14
15 std::cout << "red2: " << red2 << std::endl;
16
17 // int red= 5; ERROR
18
19 std::cout << std::endl;
20
21
22 }
Verwendet das Programm keinen Namen für die Aufzählung »enum{red, green, blue};«, führt das die Aufzähler zumindest in den umgebenden Bereich ein.
Streng typisiert
Im modernen C++ gelten für die streng typisierten Aufzählungstypen deutlich strengere Regeln:
- Die Aufzähler lassen sich in aktuellen C++-Programmen stets nur innerhalb des Gültigkeitsbereichs der Aufzählung ansprechen.
- Die Aufzähler konvertieren nicht implizit zu »int«.
- Die Aufzählungstypen führen ihren Aufzähler nicht in den umgebenden Bereich ein.
- Als zugrunde liegender Typ kommt standardmäßig »int« zum Einsatz, sodass C++ Aufzählungstypen automatisch vorwärts deklariert.
Der syntaktische Unterschied zwischen den klassischen Aufzählungstypen und den streng typisierten Aufzählungstypen bleibt dabei minimal. Letztere ergänzt der Programmierer bei ihrer Deklaration um die Schlüsselwörter »class« oder »struct« (Abbildung 10). Will er den Aufzähler eines streng typisierten Aufzählungstyps als Datentyp »int« verwenden, muss er ihn mit »static_cast()« konvertieren (Listing 8).
Listing 8
Explizites Konvertieren streng typisierter Aufzählungstypen
01 #include <iostream>
02
03 enum OldEnum{
04 one= 1,
05 ten=10,
06 hundred=100,
07 thousand= 1000
08 };
09
10 enum struct NewEnum{
11 one= 1,
12 ten=10,
13 hundred=100,
14 thousand= 1000
15 };
16
17 int main(){
18
19 std::cout << std::endl;
20
21 std::cout << "C++11= " << 2*thousand +0*hundred + 1*ten + 1*one << std::endl;
22 std::cout << "C++11= " << 2*static_cast<int>(NewEnum::thousand) +0*static_cast<int>(NewEnum::hundred) +1*static_cast<int>(NewEnum::ten) +1*static_cast<int>(NewEnum::one) << std::endl;
23
24 std::cout << std::endl;
25
26 }
Um die Aufzähler zu verrechnen oder auszugeben, muss der Entwickler sie in einen integralen Typ konvertieren. Dabei ist weder die Addition noch die Ausgabe für Aufzähler von streng typisierten Aufzählungstypen definiert (Abbildung 11).
Dieser Artikel sprach bislang stets von klassischen und streng typisierten Aufzählungstypen, die gern auch Aufzählungen mit und ohne Geltungsbereich heißen.
Eine Erweiterung der neuen Aufzählungstypen in C++11 kam aber bislang nicht zu Wort. In C++11 lässt sich explizit der zugrunde liegende Typ der Aufzählung spezifizieren. Per Default ist es »int«. Es geht aber auch anders. Als Typ kann der Entwickler auch einen integralen Typ wie »bool«, »char«, »short int«, »int«, »long int« oder auch »long long int« heranziehen.

Abbildung 12: Verschieden große Aufzählungstypen erzeugt Listing 9.
Der Geltungsbereich und die explizite Typangabe einer Aufzählung lassen sich unabhängig voneinander einsetzen. Abhängig von ihrem Typ fallen die Aufzählungstypen dann unterschiedlich groß aus, wie Listing 9 demonstriert.
Listing 9
Explizite Typangaben für die Aufzähler
01 #include <iostream>
02 #include <limits>
03
04 enum struct Colour0: bool{
05 red, // 0
06 blue // 1
07 };
08
09 enum Colour1{
10 red= -5,
11 blue, // -4
12 green // -3
13 };
14
15 enum struct Colour2: char{
16 red= 100,
17 blue, // 101
18 green // 102
19 };
20
21 enum class Colour3: long long int{
22 red= std::numeric_limits<long long int>::min(),
23 blue= std::numeric_limits<long long int>::min() + 1,
24 green= std::numeric_limits<long long int>::min() + 2
25 };
26
27 int main(){
28
29 std::cout << std::endl;
30
31 std::cout << "sizeof(Colour0)= " << sizeof(Colour0) << std::endl;
32 std::cout << "sizeof(Colour1)= " << sizeof(Colour1) << std::endl;
33 std::cout << "sizeof(Colour2)= " << sizeof(Colour2) << std::endl;
34 std::cout << "sizeof(Colour3)= " << sizeof(Colour3) << std::endl;
35
36 std::cout << std::endl;
37
38 std::cout << "Colour0::red: " << static_cast<bool>(Colour0::red) << std::endl;
39 std::cout << "red: " << red << std::endl;
40 std::cout << "Colour2::red: " << static_cast<char>(Colour2::red) << std::endl;
41 std::cout << "Colour3::red: " << static_cast<long long int>(Colour3::red) << std::endl;
42
43 std::cout << std::endl;
44
45 }
Die Ausgabe des Programms zeigt in Abbildung 12 schön, dass der Aufzählungstyp »Colour0« (Zeile 31) 1 Byte groß ist. Dem gegenüber schlagen »Colour1« mit 4 Bytes und »Colour3« mit 8 Bytes zu Buche. Damit lassen sich sehr große Aufzähler (Zeilen 22 bis 24) verwenden. Der dort ebenfalls verwendete Ausdruck »std::numeric_limits<long long int>::min« gibt den kleinsten Wert für den Datentyp »long long int« zurück.
Wie geht’s weiter?
Der Artikel in der übernächsten Ausgabe wendet sich dann dem zweiten der zehn Gebote zu, das da lautet: “Programmiere deklarativ.” Oder in den Worten des bekannten Zen of Python [6]: “Explicit is better than implicit.” Sie sind neugierig? Das ist verständlich.
Infos
-
Rainer Grimm, “Von der Theorie zur Praxis”: Linux-Magazin 12/16, S. 100, https://www.linux-magazin.de/Ausgaben/2016/12/C
-
GNU GCC: https://gcc.gnu.org
-
Clang: http://clang.llvm.org
-
Microsofts Visual Compiler: http://landinghub.visualstudio.com/visual-cpp-build-tools
- »NULL«-Literal: http://en.cppreference.com/w/cpp/types/NULL
-
Zen of Python: https://www.python.org/dev/peps/pep-0020/















