C++ Core Guidelines: Regeln für Template-Metaprogrammierung

Modernes C++  –  5 Kommentare

Ja, du hast es richtig gelesen. Heute geht es in meinem Artikel um Template-Metaprogrammierung. Sie ist Programmierung mit Datentypen und nicht mit Werten.

Die Einleitung zur Template-Metaprogrammierung in den Guidelines endet einzigartig: "The syntax and techniques needed are pretty horrendous." Entsprechend enthalten die Regel vor allem Don'ts und besitzen nicht viel Inhalt.

Ich finde Template-Metaprogrammierung nicht so grauenhaft, aber es steckt noch einiges Potenzial in der Syntax. Gerne will ich sie ein wenig entmystifizieren und über die Programmierung zur Compilezeit im Allgemeinen schreiben. Während dieser Einführung in die Programmierung zur Compilezeit, werde ich explizit auf die Type-Traits Bibliothek (T.124: Prefer to use standard-library TMP facilities) und constexpr Funktionen (T.123: Use constexpr functions to compute values at compile time) eingehen und die weiteren Regeln zur Template-Metaprogrammierung implizit adressieren.

Hier ist mein Plan: Ich gebe eine Einführung zur Template-Metaprogrammierung, zeige, wie die Type-Traits-Bibliothek Template-Metaprogrammierung in einer wohldefinierten und plattformunabhängigen Form anbietet und wie sich constexpr-Funktionen verwenden lassen, um Template-Metaprogrammierung durch gewöhnliche Funktionen zu ersetzen.

Template-Metaprogrammierung

1994 stellte Erwin Unruh bei einem C++-Standardisierungsmeeting ein Programm vor, das sich nicht übersetzen ließ. Dies ist wohl das berühmteste Programm, das nicht erfolgreich kompiliert werden konnte:

// Prime number computation by Erwin Unruh
template <int i> struct D { D(void*); operator int(); };

template <int p, int i> struct is_prime {
enum { prim = (p%i) && is_prime<(i > 2 ? p : 0), i -1> :: prim };
};

template < int i > struct Prime_print {
Prime_print<i-1> a;
enum { prim = is_prime<i, i-1>::prim };
void f() { D<i> d = prim; }
};

struct is_prime<0,0> { enum {prim=1}; };
struct is_prime<0,1> { enum {prim=1}; };
struct Prime_print<2> { enum {prim = 1}; void f() { D<2> d = prim; } };
#ifndef LAST
#define LAST 10
#endif
main () {
Prime_print<LAST> a;
}

Unruh verwendete den Metaware-Compiler. Da das Programm kein gültiges C++ mehr darstellt, hat der Autor hier eine moderne Variante hinterlegt. Warum ist das Programm so berühmt? Hier ist ein Blick auf seine Fehlermeldungen:

Ich habe die wichtigen Komponenten der Fehlermeldung in rot hinterlegt. Ich denke, du siehst das Pattern. Das Programm berechnet zur Übersetzungszeit die ersten 30 Primzahlen. Das heißt, Template-Metaprogrammierung kann dazu verwendet werden, zur Übersetzungszeit zu rechnen. Es wird noch besser. Template-Metaprogrammierung ist Turing-complete. Damit lässt sich jedes berechenbare Problem lösen. (Klar, die Turing-Vollständigkeit gilt nur in Theorie für die Template-Metaprogrammierung, denn die Rekursionstiefe (zumindest 1024 mit C++11) und die Länge der Namen, die der Compiler während der Template-Instanziierung erzeugt, sind limitierende Faktoren.)

Wie funktioniert die ganze Magie?

Ich möchte traditionell beginnen.

Berechnungen zur Compilezeit

Die Berechnung der Fakultät einer Zahl stellt das "Hello World" der Template-Metaprogrammierung dar:

// factorial.cpp

#include <iostream>

template <int N> // (2)
struct Factorial{
static int const value = N * Factorial<N-1>::value;
};

template <> // (3)
struct Factorial<1>{
static int const value = 1;
};

int main(){

std::cout << std::endl;

std::cout << "Factorial<5>::value: " << Factorial<5>::value << std::endl; // (1)
std::cout << "Factorial<10>::value: " << Factorial<10>::value << std::endl;

std::cout << std::endl;

}

Der Aufruf Factorial<5>::value in Zeile (1) stößt die Instanziierung des primären oder allgemeinen Templates in Zeile (2) an. Während dieser Instanziierung, wird Factorial<4>::value instanziiert. Diese Rekursion endet, wenn das vollständig spezialisierte Klasse-Template Factorial<1>::value zum Zuge kommt. Vielleicht ziehst du ein Bild meiner Erklärung vor:

Hier ist die Ausgabe des Programms:

Jetzt habe ich doch beinahe vergessen zu beweisen, dass die Werte bereits zur Compilezeit berechnet werden. Dies ist der Beweis mithilfe des Compiler Explorer. Der Einfachheit halber stellte ich nur das main-Programm und die entsprechenden Assembler Befehle gegenüber:

Die erste gelbe und die erste lilafarbene Zeile bringen es auf den Punkt. Die Fakultäten von 5 und 10 sind Konstanten, die bereits zur Compilezeit berechnet wurden. Zugegeben, das Fakultätsprogramm ist ein nettes Beispiel, aber nicht idiomatisch für Template Metaprogrammierung.

Typmodifikationen zur Compilezeit

Die Modifikation von Datentypen zur Compilezeit ist typisch für die Template-Metaprogrammierung. Falls du mir nicht glaubst, studiere std::move. Der kleine Codeschnipsel zeigt, was konzeptionell hinter std::move steckt:

static_cast<std::remove_reference<decltype(arg)>::type&&>(arg);

std::move nimmt ein Argument arg an, deduziert seinen Datentyp (decltype(arg)), entfernt gegebenenfalls von diesem die Referenz (remove_reverence) und konvertiert diesen zu einer Rvalue-Referenz (static_cast<...>::type&&>). std::move gibt immer eine Rvalue-Referenz zurück. Damit kann Move-Semantik angewandt werden.

Wie funktioniert nun std::remove_reference aus der Type-Traits Bibliothek? Hier ist ein Codeschnipsel, das zeigt, wie sich die Konstanz seines Argument entfernen lässt:

template<typename T > 
struct removeConst{
typedef T type; // (1)
};

template<typename T >
struct removeConst<const T> {
typedef T type; // (1)
};


int main(){

std::is_same<int, removeConst<int>::type>::value; // true
std::is_same<int, removeConst<const int>::type>::value; // true

}

ich habe removeConst in der Art als Klassen-Template implementiert, wie es wohl auch von Type-Traits-Bibliothek unter dem Namen std::remove_const angeboten wird. std::is_same ist eine praktische Funktion aus der Type-Traits Bibliothek, die zur Compilezeit die Frage beantwortet, ob zwei Datentypen identisch sind. Im Falle des removeConst<int>-Aufrufs kommt das allgemeine oder auch primäre Template zum Einsatz; im Falle von removeConst<const int> wählt der Compiler die teilweise Spezialisierung für const T aus. Die entscheidende Beobachtung ist es, dass beide Klassen-Templates den zugrunde liegenden nichtkonstanten Datentyp in Zeile (1) zurückgeben.

Wie geht's weiter?

Im nächsten Artikel geht meine Einführung in die Programmierung zum Compilezeit weiter. Das heißt insbesondere, dass ich Funktionen und Metafunktionen vergleiche, bevor ich mich mit der Type-Traits-Bibliothek beschäftige.