Programmieren in C++: Aufbau
Dieses Buch ist unter einer Creative Commons-Lizenz lizensiert.
C++ unterstützt im Wesentlichen zwei Paradigmen: Die objektorientierte und die generische Programmierung. Die Objektorientierung ist dabei das bekanntere Paradigma, das von C++-Entwicklern auch tagtäglich eingesetzt wird. Es ist genau genommen so bekannt, dass viele Entwickler C++ ausschließlich für eine objektorientierte Programmiersprache halten.
C++ ist ohne die generische Programmierung aber eigentlich nicht vorstellbar. So basiert die C++ Standardbibliothek zu großen Teilen auf der generischen Programmierung. Da eine effiziente Softwareentwicklung ohne die C++ Standardbibliothek kaum möglich ist, ist es wichtig zu verstehen, was sich hinter der generischen Programmierung verbirgt.
Der zentrale Baustein in der generischen Programmierung in C++ sind Templates. Templates sind Schablonen, die in gewisser Weise unvollständigen C++-Code darstellen. Templates an sich werden vom Compiler auch nicht in Binärcode übersetzt und sind dementsprechend nicht als Binärcode in einem ausführbaren Programm enthalten.
Templates sind Schablonen, die vom Compiler aufgegriffen werden, um nach bestimmten Regeln normalen C++-Code zu erstellen, der dann in Binärcode übersetzt wird. Wie diese Schablonen definiert werden und wie der Compiler vorgeht, um basierend auf Schablonen normalen C++-Code zu erstellen, lernen Sie in diesem Kapitel kennen.
Stellen Sie sich vor, Sie müssen in Ihrem Programm verschiedene Datentypen nach std::string
umwandeln, weil Sie die Daten zum Beispiel in einer Text-Datei speichern wollen. Sie wissen, dass die C++ Standardbibliothek mit std::ostringstream
eine Klasse zur Verfügung stellt, die den Operator <<
überlädt, so dass Variablen unterschiedlicher Datentypen übergeben und in einen Stringstream geschrieben werden können. Um Variablen vom Typ int
und float
umzuwandeln, entwickeln Sie daher die folgenden Funktionen.
#include <string> #include <sstream> std::string convert_to_string(int i) { std::ostringstream ss; ss << i; return ss.str(); } std::string convert_to_string(float f) { std::ostringstream ss; ss << f; return ss.str(); }
Möchten Sie nun eine Variable vom Typ int
oder float
in ein std::string
umwandeln, müssen Sie lediglich die Funktion convert_to_string()
aufrufen. Soll auch die Umwandlung anderer Datentypen unterstützt werden, müssen Sie weitere Funktionen hinzufügen, die ähnlich wie die oben gezeigten Funktionen definiert werden.
Die beiden oben angegebenen Funktionen zur Umwandlung von int
- und float
-Variablen sehen einander bereits sehr ähnlich. Genaugenommen unterscheiden sie sich lediglich im Datentypen des Parameters. Um die Umwandlung neuer Datentypen zu unterstützen, müssten quasi immer gleiche Funktionen definiert werden. Das ist nicht nur eintönig und lästig, sondern kann auch langfristig unnötig Arbeit verursachen. Da der exakt gleiche Code in mehreren Funktionen Verwendung findet, müsste der gleiche Code mehrmals geändert werden, wenn zum Beispiel ein Problem auftauchen würde und ein Bug behoben werde müsste.
Templates kommen dem C++-Programmierer nun zu Hilfe. Anstatt als C++-Programmierer selber die quasi gleiche Funktion mehrmals zu definieren, kann ein einziges Template definiert werden. Der Compiler wird dann das Template nehmen und normalen C++-Code erstellen, der genau dem entspricht, den der C++-Programmierer selber durch die mehrfache Funktionsdefinition hätte schreiben können. Anstatt die Arbeit aber selbst zu tun wird sie uns vom Compiler abgenommen.
Die obigen beiden Funktionen sehen zusammengefasst in ein Template wie folgt aus.
template <typename T> std::string convert_to_string(T t) { std::ostringstream ss; ss << t; return ss.str(); }
Um ein Template zu erstellen, wird das Schlüsselwort template
der entsprechenden Funktion vorangestellt. Hinter template
werden in spitze Klammern Parameter angegeben. Bei diesen Parametern handelt es sich um Platzhalter, die später durch Datentypen ersetzt werden. Da der Platzhalter also selbst durch jeden beliebigen Datentyp ersetzt werden können muss, wird ihm das Schlüsselwort typename
vorangestellt. Im obigen Template kann der Parameter T
also durch einen beliebigen Datentypen ersetzt werden.
Der Parameter T
wird nun überall dort angegeben, wo später der Datentyp, der T
ersetzt, verwendet werden soll. Das ist im Beispiel hier lediglich im Funktionskopf von convert_to_string()
der Fall. Der Funktionsparameter t von convert_to_string()
erhält als Datentyp den Template-Parameter T
.
Wenn ein Compiler ein Template wie oben sieht, macht er gar nichts. Er übersetzt das Template nicht in Binärcode. Würden Sie obiges Template einer Quellcode-Datei hinzufügen, der Compiler dann ein ausführbares Programm erstellen und Sie den Binärcode des Programms untersuchen, würden Sie das Template nicht finden.
Ein Template ist lediglich eine Schablone. Es handelt sich nicht um normalen C++-Code, der vom Compiler in Binärcode umgewandelt wird. Stattdessen muss der Compiler mit dem Template normalen C++-Code erstellen, wenn das Template dann eingesetzt wird.
#include <string> #include <sstream> #include <iostream> template <typename T> std::string convert_to_string(T t) { std::ostringstream ss; ss << t; return ss.str(); } int main() { int i = 1; std::cout << convert_to_string(i) << std::endl; }
Um die Template-Funktion convert_to_string()
zu verwenden, muss sie aufgerufen werden. Im obigen Beispiel wird dazu eine Variable vom Typ int
definiert, mit der in main()
die Funktion convert_to_string()
aufgerufen wird.
Wenn der Compiler obigen Code übersetzt, sieht er, dass das Template convert_to_string()
eingesetzt wird. Im Template wird ein Platzhalter für den Datentypen des einzigen Parameters der Funktion verwendet. Da die Funktion convert_to_string()
hier mit einer Variablen vom Typ int
aufgerufen wird, erkennt der Compiler, dass der Platzhalter T
im Template durch int
zu ersetzen ist. Sie können das automatische Erkennen des Datentyps verhindern, wenn Sie das Template explizit mit dem zu verwendenden Datentypen aufrufen.
#include <string> #include <sstream> #include <iostream> template <typename T> std::string convert_to_string(T t) { std::ostringstream ss; ss << t; return ss.str(); } int main() { int i = 1; std::cout << convert_to_string<int>(i) << std::endl; }
Egal, ob Sie der Template-Funktion den Datentypen explizit übergeben oder den Compiler den Datentypen automatisch erkennen lassen: Der Compiler generiert nun automatisch basierend auf dem Template normalen C++-Code. Dieser automatisch generierte C++-Code ist nirgendwo sichtbar und wird zum Beispiel auch nicht in den Quellcode eingefügt, so dass ihn der Programmierer sehen könnte. Er wird automatisch vom Compiler basierend auf dem Template erzeugt und in Binärcode übersetzt. Für das obige Beispiel bedeutet dies, dass der Compiler eine Funkion convert_to_string()
mit einem Parameter vom Typ int
übersetzt, so als hätten Sie sie selbst definiert.
Während bei einem einmaligen Aufruf einer Funktion das Template nicht viel Sinn macht - die Funktion muss ja mindestens einmal definiert werden, egal ob als Template oder als normaler C++-Code - ergibt sich bei mehrmaligem Aufruf mit Parametern unterschiedlichen Typs eine Arbeitsersparnis.
#include <string> #include <sstream> #include <iostream> template <typename T> std::string convert_to_string(T t) { std::ostringstream ss; ss << t; return ss.str(); } int main() { int i = 1; std::cout << convert_to_string(i) << std::endl; float f = 1.5; std::cout << convert_to_string(f) << std::endl; bool b = true; std::cout << convert_to_string(b) << std::endl; }
Der Compiler generiert nun basierend auf dem Template drei Funktionen, die unterschiedliche Parameter vom Typ int
, float
und bool
erwarten. Anstatt die drei Funktionen selbst zu programmieren, ist es von Vorteil, ein Template zu erstellen und den Compiler die jeweils benötigten Funktionen automatisch generieren zu lassen.
Während es hilfreich ist, dass der Compiler von einem Template ausgehend normalen C++-Code für unterschiedliche Datentypen erstellt, kann es hin und wieder notwendig sein, dass für bestimmte Datentypen die Implementation anders aussehen muss - weil zum Beispiel das Template mit bestimmten Datentypen nicht kompatibel ist oder weil für bestimmte Datentypen aus Performance-Gründen die Implementation angepasst werden soll.
#include <string> #include <sstream> #include <iostream> template <typename T> std::string convert_to_string(T t) { std::ostringstream ss; ss << t; return ss.str(); } struct person { person(std::string n) : Name(n) { } std::string Name; }; int main() { person boris("Boris"); std::cout << convert_to_string(boris) << std::endl; }
Im obigen Beispiel wird eine Struktur namens person
definiert und innerhalb von main()
instantiiert. Dem Konstruktor wird der Name Boris übergeben, der in der Eigenschaft Name der Struktur gespeichert wird. Dann wird versucht, das Objekt boris in einen String zu konvertieren, indem es als Parameter an die Funktion convert_to_string()
übergeben wird.
Wenn Sie versuchen sollten, obigen Code zu kompilieren, stellen Sie fest, dass der Compiler Ihnen einen Fehler meldet. Innerhalb der Funktion convert_to_string()
wird nämlich der Parameter an std::ostringstream
weitergereicht. Diese der C++-Standardbibliothek entnommene Klasse kennt aber die Struktur person
nicht und besitzt keinen überladenen Operator <<
, der einen Parameter vom Typ person
akzeptieren würde.
Um die benutzerdefinierte Struktur person
nicht unnötigerweise anders behandeln zu müssen als andere Datentypen wie int
, float
oder bool
, kann das Template spezialisiert werden.
#include <string> #include <sstream> #include <iostream> template <typename T> std::string convert_to_string(T t) { std::ostringstream ss; ss << t; return ss.str(); } struct person { person(std::string n) : Name(n) { } std::string Name; }; template <> std::string convert_to_string<person>(person t) { std::ostringstream ss; ss << "Person: " << t.Name; return ss.str(); } int main() { person boris("Boris"); std::cout << convert_to_string(boris) << std::endl; }
Spezialisierung bedeutet, dass das Template für einen bestimmten Datentypen angepasst wird. Dazu wird das Template ein zweites Mal definiert. Die Template-Parameter, die vorher Platzhalter für einen beliebigen Datentypen waren, werden aus den spitzen Klammern hinter template
entfernt und durch Datentypen ersetzt, für die das Template spezialisiert wird. Diese Datentypen müssen hierbei in spitzen Klammern hinter dem Funktionsnamen angegeben werden.
Wenn nun wie oben die Funktion convert_to_string()
mit einem Parameter vom Typ person
aufgerufen wird, greift der Compiler auf das für person
spezialisierte Template zu und generiert basierend auf diesem Template normalen C++-Code. Für andere Datentypen wie int
, float
oder bool
wird wie zuvor das allgemeine, nicht-spezialisierte Template instantiiert.
Wenn Sie sich die spezialisierte Template-Funktion angucken, fällt Ihnen vielleicht auf, dass Sie dort auf das Schlüsselwort template
hätten verzichten können. Sie können stattdessen eine ganz normale C++-Funktion definieren, die als ersten Parameter eine Variable vom Typ person
erwartet.
#include <string> #include <sstream> #include <iostream> template <typename T> std::string convert_to_string(T t) { std::ostringstream ss; ss << t; return ss.str(); } struct person { person(std::string n) : Name(n) { } std::string Name; }; std::string convert_to_string(person t) { std::ostringstream ss; ss << "Person: " << t.Name; return ss.str(); } int main() { person boris("Boris"); std::cout << convert_to_string(boris) << std::endl; }
In der Tat ist es besser, auf die Spezialisierung von Template-Funktionen zu verzichten und stattdessen die Funktion als normalen C++-Code zu überladen - so wie Sie es bisher von C++ ohne Templates kennen. Der Compiler versucht bei einem Aufruf zuerst, Funktionen zu finden, die nicht als Template definiert sind. Erst dann, wenn keine zum Aufruf passende Funktion gefunden wurde, überprüft er Template-Funktionen. Wenn aber mehrere Template-Funktionen definiert sind und diese außerdem für bestimmte Datentypen spezialisiert sind, ist es nicht mehr allzu leicht vorherzusagen, welche Template-Funktion der Compiler auswählen wird. Werden stattdessen spezialisierte Template-Funktionen als normale C++-Funktionen ohne template
definiert, werden sie immer gegenüber Template-Funktionen bevorzugt und es kann zu keinen unerwünschten Folgen kommen.
So wie das Schlüsselwört template
auf Funktionen angewandt werden kann, können mit ihm auch Klassen generalisiert werden.
#include <stdexcept> template <typename T> class array { public: T &operator[](int index) { if (index < 0 || index > 9) throw std::runtime_error("Index ungueltig"); return data[index]; } private: T data[10]; };
Im obigen Beispielcode ist eine Klasse array
definiert, mit der ein Datenfeld bestehend aus 10 Feldern verwaltet werden kann. Um einem Entwickler die Wahl zu lassen, welche Art von Daten im Datenfeld verwaltet werden soll, ist array
ein Template.
So wie bei Template-Funktionen macht der Compiler auch bei Template-Klassen erstmal nichts. Erst wenn auf das Template zugegriffen wird, generiert der Compiler normalen C++-Code, der dann übersetzt wird.
#include <stdexcept> #include <iostream> template <typename T> class array { public: T &operator[](int index) { if (index < 0 || index > 9) throw std::runtime_error("Index ungueltig"); return data[index]; } private: T data[10]; }; int main() { array<int> a; a[0] = 1; std::cout << a[0] << std::endl; }
Im obigen Beispiel wird die Template-Klasse array
mit dem Datentyp int
innerhalb von main()
instantiiert. So wie bei Template-Funktionen ersetzt der Compiler den Template-Parameter durch den angegebenen Datentypen. Da ebenso wie bei Template-Funktionen eine Template-Klasse mit unterschiedlichen Datentypen instantiiert werden kann, generiert der Compiler jeweils den notwendigen normalen C++-Code automatisch, ohne dass ein Entwickler mehrmals eine Klasse array
für unterschiedliche Datentypen definieren muss.
Alle Container aus der C++-Standardbibliothek wie std::vector
, std::list
oder std::deque
sind grundsätzlich so definiert wie array
. In den Containern aus der C++-Standardbibliothek lassen sich Daten unterschiedlicher Typen speichern, weil alle Container Template-Klassen sind.
So wie bei Template-Funktionen können auch Template-Klassen spezialisiert werden.
#include <stdexcept> #include <iostream> template <typename T> class array { public: T &operator[](int index) { if (index < 0 || index > 9) throw std::runtime_error("Index ungueltig"); return data[index]; } private: T data[10]; }; template <> class array<float> { public: float &operator[](int index) { if (index < 0 || index > 999) throw std::runtime_error("Index ungueltig"); return data[index]; } private: float data[1000]; }; int main() { array<int> i; i[0] = 1; std::cout << i[0] << std::endl; array<float> f; f[999] = 1; std::cout << f[999] << std::endl; }
Wenn zum Beispiel für Arrays vom Typ float
mehr Felder benötigt werden, könnte dies in einer spezialisierten Klassen wie oben geschehen angegeben werden.
Es gibt bei Template-Klassen eine besondere Art der Spezialisierung, die partielle Spezialisierung genannt wird.
#include <stdexcept> #include <iostream> template <typename T, int Length> class array { public: T &operator[](int index) { if (index < 0 || index >= Length) throw std::runtime_error("Index ungueltig"); return data[index]; } private: T data[Length]; }; int main() { array<int, 20> i; i[0] = 1; std::cout << i[0] << std::endl; }
Im obigen Beispiel wird die Klasse array
um einen zweiten Template-Parameter ergänzt. Dem zweiten Parameter wird jedoch nicht das Schlüsselwort typename
vorangestellt, sondern der Datentyp int
. Dies bedeutet, dass beim Zugriff auf die Template-Klasse als zweiter Parameter ein Wert angegeben werden muss, der zum Datentyp int
kompatibel ist. Über diese Ganzzahl kann hier in diesem Beispiel angegeben werden, aus wie vielen Feldern das Array bestehen soll.
Basierend auf den in diesem Kapitel vorgestellten Grundkenntnissen lassen sich mit Templates komplexe Programme entwickeln, die vom Compiler ausgeführt werden und deren Ergebnis normaler C++-Code ist. Das Entwickeln von Programmen auf Basis von Templates wird Meta-Programmierung genannt. Ein oft vorgestelltes Beispiel, wie die Template Meta-Programmierung aussieht, ist folgendes.
#include <iostream> template <int N> class fakultaet { public: enum { value = N * fakultaet<N - 1>::value }; }; template <> class fakultaet<1> { public: enum { value = 1 }; }; int main() { std::cout << fakultaet<4>::value << std::endl; }
Das obige Programm gibt die Fakultät von 4 aus, indem die Zahlen 1, 2, 3 und 4 miteinander multipliziert werden. Das besondere an obigem Quellcode ist aber, dass die Fakultät nicht zur Programmlaufzeit berechnet wird, sondern zur Kompilierung. Denn zur Berechnung der Fakultät wird in diesem Programm ein Template verwendet.
Die Template-Klasse fakultaet
definiert eine Enumeration, in der lediglich der Wert value
existiert. Um value
auf einen bestimmten Wert zu setzen, wird rekursiv auf die Template-Klasse fakultaet
zugegriffen. Dabei wird der jeweils um 1 verringerte Template-Parameter N
verwendet, um die rekursiv aufgerufene Template-Klasse zu instantiieren. Für den Wert 1 ist fakultaet
spezialisiert worden: value
ist hier gleich 1, so dass auch dann kein weiterer rekursiver Aufruf mehr stattfindet.
Die Template Meta-Programmierung ist Turing-vollständig. Das bedeutet, dass mit der Template Meta-Programmierung vollständige Programme erstellt werden können, die der Programmiersprache C++, wie sie ohne Templates verwendet wird, in nichts nachstehen. So können tatsächlich auch Verzweigungen und Schleifen entwickelt werden, so wie sie jeder C++-Programmierer von den Schlüsselwörtern if
und while
her kennt. Der entscheidende Unterschied aber ist, dass Meta-Programme vom Compiler und herkömmliche C++-Programme zur Laufzeit ausgeführt werden.
Die Template Meta-Programmierung hat den großen Nachteil, dass der Code bei weitem nicht so einfach zu schreiben und zu verstehen ist wie normaler C++-Code. Da in der Template Meta-Programmierung zum Beispiel keine Variablen existieren, müssen Schleifen durch die rekursive Instantiierung von Templates realisiert werden.
Ein Grund, warum die Template Meta-Programmierung sehr gewöhnungsbedürftig erscheint, ist, dass die Möglichkeiten, die sich durch Templates ergeben, eher zufällig entdeckt wurden. Es wurden nicht wirklich Entscheidungen diesbezüglich getroffen, die die Template Meta-Programmierung in C++ möglich machen sollten. Das, was heute als Template Meta-Programmierung bezeichnet wird, war ein Zufallsprodukt, das durch clevere Anwendung von Templates entdeckt wurde.
Da es keine Schlüsselwörter ähnlich wie if
und while
gibt, die in der Template Meta-Programmierung verwendet werden können, müssen Verzweigungen und Schleifen durch Templates nachgebildet werden. Es gibt Bibliotheken wie die Boost.MPL, die solche für die Template Meta-Programmierung häufig benötigten Konstrukte zur Verfügung stellen und daher von Entwicklern, die Meta-Programme schreiben wollen, verwendet werden können.
In der Praxis ist die Template Meta-Programmierung nicht weit verbreitet. Das Entwickeln von Programmen auf der Meta-Ebene ist zu kompliziert als dass sich die Meta-Programmierung bisher durchgesetzt hat. Meta-Programme, die mehr tun als nur eine Fakultät zu berechnen, rufen bei den meisten C++-Entwicklern eher ungläubiges Staunen hervor, weil der Code so völlig anders aussieht als das, was man typischerweise als normalen C++-Code bezeichnet. Moderne C++-Bibliotheken wie beispielsweise die Boost-Bibliotheken, bei deren Entwicklung viel Pionierarbeit geleistet wird, setzen verstärkt die Template Meta-Programmierung ein. In kommerziellen Produkten oder anderen praxisrelevanten Anwendungen findet die Meta-Programmierung eher selten Verwendung.
Aufgrund der Komplexität der Template Meta-Programmierung soll in der kommenden Version des C++-Standards das Schlüsselwort concept
eingeführt werden, mit dem sich Konzepte im C++-Code ausdrücken lassen sollen. Dies erfolgt ausdrücklich mit dem Ziel, die Entwicklung und Anwendung von Templates zu erleichtern. Die heutige Version des C++-Standards definiert bereits viele Konzepte, die aber lediglich in der Dokumentation existieren. Mit der neuen Version des Standards soll es möglich sein, Konzepte dann auch explizit in C++ auszudrücken. Da Konzepte einerseits Eigenschaften von Datentypen beschreiben sollen, anderseits Anforderungen von Templates an Datentypen, sollen sie ein explizites Bindeglied darstellen, das heute in C++ fehlt.
Sie können die Lösungen zu allen Aufgaben in diesem Buch als ZIP-Datei erwerben.
Entwickeln Sie eine generische Funktion austauschen()
, der zwei Referenzen auf Variablen übergeben werden können, die vom gleichen Typ sind. Die Funktion austauschen()
soll die Werte, die in den beiden referenzierten Variablen gespeichert sind, austauschen. Definieren Sie dann zum Test zwei Variablen vom Typ int
und zwei Variablen vom Typ double
in der Funktion main()
. Initialisieren Sie die Variablen mit beliebigen Werten und rufen Sie dann die Funktion austauschen()
auf. Geben Sie die Variablen anschließend zur Kontrolle auf die Standardausgabe aus.
Die Funktion austauschen()
muss auf einem Template basieren, das einen einzigen Template-Parameter erwartet. Da die zwei Parameter, die an die Funktion austauschen()
übergeben werden können, beide vom gleichen Typ sein sollen, reicht ein Template-Parameter aus.
Modifizieren Sie Ihre Lösung für Aufgabe 1, indem Sie die Variablen vom Typ double
nicht mehr lokal in der Funktion main()
definieren, sondern per new
im Heap. Wenn Sie die Zeiger auf die beiden mit new
erstellten double
-Variablen an die generische Funktion austauschen()
übergeben, tauscht sie die Adressen der Variablen aus und nicht die Werte in den Variablen. Erstellen Sie deswegen ein für double*
spezialisiertes Template, das die Werte der double
-Variablen austauscht, deren Adressen als Parameter an das spezialisierte Template übergeben werden. Geben Sie nach einem Aufruf der Funktion austauschen()
die Werte der beiden double
-Variablen zur Kontrolle auf die Standardausgabe aus.
Für diese Aufgabe müssen Sie die Funktion austauschen()
für double*
spezialisieren.
Ändern Sie Ihre Lösung für Aufgabe 1 dahingehend, als dass es möglich sein soll, die generische Funktion austauschen()
mit Variablen unterschiedlicher Datentypen aufzurufen. Rufen Sie zum Test austauschen()
mit einer int
- und double
-Variablen auf und geben Sie die Werte der Variablen anschließend zur Kontrolle auf die Standardausgabe aus.
Die Funktion austauschen()
muss nun zwei Template-Parameter besitzen.
Entwickeln Sie eine generische Klasse person
, die drei Eigenschaften Name, Alter und Gehalt besitzt. Die Datentypen aller drei Eigenschaften sollen über Template-Parameter gesetzt werden können. Instantiieren Sie dann die Klasse person
mit beliebigen Datentypen, indem Sie ein Objekt in der Funktion main()
erstellen. Greifen Sie über Methoden, die Sie zusätzlich in der Klasse person
definieren sollen, auf das Objekt zu, um Eigenschaften auf bestimmte Werte setzen zu können und Eigenschaften zur Kontrolle per std::cout auf den Monitor ausgeben zu können.
Copyright © 2001-2010 Boris Schäling