Ein kleiner Blick auf die generische Programmierung

Generische Programmierung sollte für den heutigen modernen Entwickler durchaus zur Trickkiste gehören. Es geht darum, Funktionen, Strukturen oder Klassen möglichst allgemein zu entwerfen, ohne genauere Datentypen zu verwenden und diese praktisch zur Laufzeit/ beim Kompilieren zu generieren.

Ein kleines Beispiel:

Nehmen wir an, wir würden uns einen Taschenrechner schreiben der zwei Werte miteinander addieren soll. Dabei befinden wir uns in einer getypten Programmiersprache (C++ / Java).
Wenn wir nun Ganzzahlen betrachten, sollte dies kein Problem sein – ein einfacher Integer sollte durchaus für unsere Zwecke reichen. Doch wie handhaben wir es, wenn der Benutzer eine Zahl mit beliebig vielen Nachkommastellen angibt? Benutzten wir hier eine Typumwandlung und ignorieren die Nachkommastellen, oder schreiben wir eine zweite Funktion AddKomma?
Und genau hier kommt unsere generische Programmierung ins Spiel…

Templates

Templates werden in Programmiersprachen als „Platzhalter“ benutzt, wobei erst zur Übersetzungszeit/Laufzeit bekannt ist, welcher Datentyp für diesen Platzhalter eingesetzt wird.
Anstatt nun direkt den Datentyp Integer für die Funktion add zu nehmen, benutzen wir ein Template

template <typename T>
T Add(T val1, T val2)
{
    return val1 + val2;
}

Nun können wir per

Add<float>(12,15.333)

zwei Variablen des gleichen Typs miteinander addieren, wobei wir zwischen den <> den Datentyp angeben können. Um verschiedene Datentypen miteinander zu addieren und dabei automatisch die Nachkommastellen zu berücksichtigen ist ein kleiner Trick notwendig. Wir benutzen std::ceil um zu überprüfen, ob eine Zahl eine Nachkommastelle besitzt. Anschließend rufen wir unsere Funktion „Add“ mit dem Datentyp float auf. Dabei werden unsere beiden Datentypen zu float umgewandelt.

#include <iostream>
#include <cmath>

using namespace std;

template <typename T>
T Add(T val1, T val2);

int main()
{
    float kommazahl = 10.99;
    if(kommazahl < std::ceil(kommazahl))
        std::cout << Add<float>(kommazahl,kommazahl) << std::endl;
    else
        std::cout << Add<int>(kommazahl,kommazahl) << std::endl;
   return 0;

}

template <typename T>
T Add(T val1, T val2)
{
    return val1 + val2;
}

Verschiedene Datentypen

Bisher hatten wir unsere Funktion „Add“ so definiert, dass wir ihr nur zwei Parameter des gleichen Typs angeben konnten. Dies soll sich jetzt ändern und es ist denkbar einfach.
Anstatt

template <typename T>
T Add(T val1, T val2);

fügen wir einen weiteren typename hinzu:

template <typename T, typename F>
T Add(T val1, F val2);

Nun können wir zwei Variablen verschiedener Datentyps miteinander addieren, leider verlieren wir dennoch die Nachkommastellen, da bei dem operator+() automatisch eine Typumwandlung (zum Beispiel zu einem Integer) stattfindet.

Variadic Templates

Zum einen bieten Variadic Templates den Vorteil, dass diese Funktionen beliebig viele Parameter empfangen können und zum anderen diese Parameter auch einen anderen Typ haben dürfen. Ich übernehme mal das Beispiel von Rainer Grimm (C++11 – Der Leitfaden für Programmierer zum neuen Standard, 2012 Addison-Wesley Verlag) und zeige euch den gekürzten Code

#include <iostream>
#include <iomanip>
#include <typeinfo>

template<typename T>
void PrintType(T Value)
{
    std::cout << typeid(Value).name() << std::endl;
}

template<typename T>
void PrintInfo(T value)
{
    PrintType(value);
}

template<typename First,typename ... Rest>
void PrintInfo(First first,Rest ...rest)
{
    PrintType(first);
    PrintInfo(rest...);
}

int main(int argc, char** argv)
{
    PrintInfo(1,2,3,"true",true,13.66,(float) 13.99);
    return 0;
}

Wie ihr durchaus richtig vermutet sind die drei Punkte das wesentliche bei den Variadic Templates.
Falls diese vor der Variable stehen

Rest ...rest

so wird der Rest der Parameter in die Variable rest gepackt. Falls diese wie bei dem rekursivem Funktionsaufruf wie bei PrintInfo stehen, so wird die Parameterliste entpackt.
Damit auch das letzte Element der Liste ausgelesen wird, muss die Funktion mit nur einem Parameter definiert werden.

Um zurück auf unser Problem mit den Nachkommastellen zu kommen:
Man könnte nun eine rekursive Funktion mithilfe von Variadic Templates schreiben, die alle Parameter der Liste durchgeht und den „größten/gewünschten“ Wert zurückgibt, schreiben. In unserem Beispiel wäre das eine Funktion, die zwischen int, double und float unterscheidet. Je nachdem welchen Rückgabewert diese Funktion besitzt, kann man dann alle Variablen die man addieren möchte in den gewünschten Datentyp umwandeln. Hier ein kleines Beispiel wie man den „größten“ Datentyp von Zahlen ermitteln kann:

template<typename T>
    char GetBiggest(char c,T value)
    {
        char n = *typeid(value).name();
        // int < float < double
        if(c == 'i' && (n == 'f' || n == 'd'))
            c = n;

        if(c == 'f' && n == 'd')
            c = n;

        return c;
    }

    template<typename First,typename ... Rest>
    char GetBiggest(char c,First first,Rest ...rest)
    {
        c = GetBiggest(c,first);
        c = GetBiggest(c,rest...);
        return c;
    }

Anschließend muss man nur per switch-Anweisung die passende Add-Funktion aufrufen.

Unterschied zwischen den Programmiersprachen

Bisher habe ich alle Beispiele in C++ (C++11) geschrieben, allerdings sind Templates nicht nur auf C++ beschränkt. Andere Programmiersprachen (wie Java) bieten dies ebenfalls an. Dennoch gibt es einen kleinen, aber feinen Unterschied. Während bei C++ die Generierung auf Kosten der Übersetzungszeit läuft, muss bei Java die Laufzeit her halten. Wenn wir in C++ eine Template-Funktion erstellen, überprüft der Compiler welche möglichen Datentypen bei der Funktion auftreten können, und generiert diese für uns. Damit sparen wir zwar Tipparbeit, allerdings der Compiler hat jede Menge zu tun. Bei Java hingegen wird nicht jede Funktion erneut generiert, sondern die JVM kümmert sich um die passenden Aufrufe zur Laufzeit. Dadurch wird das Programm unter Java wesentlich langsamer, lässt sich allerdings schneller in Bytecode übersetzen.

Abschließende Worte

Ich muss zugeben, dass mir das am Anfang gewählte Beispiel zum Schluss einige Schwierigkeiten eingebrockt hat. Eigentlich sollte der Programmierer wissen, welche Datentypen er zu welcher Zeit benutzen muss und welche er getrost ignorieren kann. Da wir bei einer Funktion fast immer wissen, welche Datentypen wir als Parameter übergeben, ist es unnötig diese noch extra zu überprüfen (wie in dem Taschenrechnerbeispiel).
Dennoch hoffe ich, dass ihr Spaß beim Lesen hattet und etwas neues lernen konntet.

PDF-Version

mfg
euer TgZero