13
Metaprogrammierung mit Templates

Dieses Kapitel behandelt die folgenden Themen:

Image       Template-Metaprogrammierung

Image       Variadische Funktions-Templates

Image       Fold-Expressions

Image       Variadische Klassen-Templates

Image       Type Traits

Image       Concepts

Das Schreiben von Programmcode, der selbst Programme oder Programmteile erzeugt, nennt man Metaprogrammierung. Metaprogrammierung ist ein in mehreren Fachgebieten und Programmiersprachen vorkommender Begriff. Hier geht es ausschließlich um Metaprogrammierung mit C++-Templates. Wenn Sie ein Template für eine Klasse oder eine Funktion schreiben, sehen Sie einen Platzhalter vor. Erst der Compiler erzeugt bei der Instanziierung des Templates eine Klasse oder eine Funktion mit einem konkreten Datentyp. Wie bei constexpr ist die Auswertung zur Compilationszeit wesentliches Merkmal: Alles, was zu dieser Zeit bereits erledigt werden kann, benötigt später keine Laufzeit und trägt zur Geschwindigkeit des entstehenden Programms bei. Bei Templates kann der Platzhalter durch einen konkreten Datentyp ersetzt werden. Es sind aber auch Platzhalter möglich, die keine Typen sind, sondern Werte. Dies zeigt das Stack-Template mit einstellbarer Größe aus Abschnitt 5.5.2. Diese Werte gehen in den aus dem Template erzeugten Datentyp ein. Die zum großen Teil darauf basierende Template-Metaprogrammierung wird in großem Umfang in der C++-Standardbibliothek eingesetzt.

13.1 Grundlagen

Zur Vertiefung des Verständnisses von Templates wird zunächst gezeigt, wie der Compiler zum Rechnen gebracht werden kann. In C++ geht es bei der Template-Metaprogrammierung jedoch meistens nicht um arithmetisches Rechnen, sondern darum, wie der Programmcode schon zur Compilationszeit optimiert werden kann, etwa indem der beste Algorithmus für einen Datentyp bestimmt wird.

Das folgende Programm berechnet eine Zweierpotenz, im Beispiel 211 = 2048. Der Compiler versucht bei der Übersetzung, den Wert des Attributs Zweihoch<11>::Wert zu ermitteln. Die klassenspezifische Aufzählungskonstante Wert hat für jeden Typ der Klasse Zweihoch, der von n abhängt, einen anderen Wert. Bei der Ermittlung stellt der Compiler fest, dass Zweihoch<11>::wert dasselbe wie 2·Zweihoch<10>::wert ist. Zweihoch<10>::wert wiederum ist dasselbe wie 2·Zweihoch<9>::wert usw. Natürlich ist dasselbe Ergebnis mit einer constexpr-Funktion zu erreichen, aber hier geht es darum, dass mithilfe von Typen gerechnet werden kann.

Die Rekursion bricht bei der Berechnung von Zweihoch<0>::wert ab, weil das Template für diesen Fall spezialisiert und der Wert mit 1 besetzt ist. Der Compiler erzeugt insgesamt zwölf Datentypen (0 bis 11), die er zur Auswertung heranzieht. Weil er konstante Ausdrücke zur Compilationszeit kennt und berechnen kann, wird an die Stelle von Zweihoch<11>::wert direkt das Ergebnis 2048 eingetragen, sodass zur Laufzeit des Programms keinerlei Rechnungen mehr nötig sind!

Diese Methode zur Berechnung von Zweierpotenzen schlägt damit jede andere, was die Rechenzeit des Programms angeht. Dieses Verfahren wird mit gutem Erfolg erweitert auf andere Probleme wie zum Beispiel die Berechnung der schnellen Fouriertransformation und die Optimierung von Vektoroperationen [Ve], stellt aber hohe Anforderungen an die verwendeten Compiler. Insbesondere ist die Tiefe der möglichen Template-Instanziierungen begrenzt.

Als Ergänzung werden im folgenden Beispiel Primzahlen vom Compiler berechnet, aber erst zur Laufzeit ausgegeben. Dabei gibt es keinerlei Schleifen oder Funktionsaufrufe, nur die statische, allerdings rekursive Konstruktion von Objekten. Die Spezialisierungen sorgen für den Abbruch der Rekursion. Anstelle der 17 kann eine andere Zahl stehen. Die mögliche Höchstzahl ist abhängig vom verwendeten Compiler. Dieses Programm wurde nach einer Idee von Erwin Unruh [Unr] geschrieben, der 1994 ein Programm konstruierte, das bei Übersetzung Primzahlen in den Fehlermeldungen des Compilers erzeugte. Für sich genommen, scheinen beide Beispiele eher Kuriositäten zu sein. Als Übung zum Verständnis des folgenden Abschnitts sind sie aber gut geeignet. Außerdem werden ähnliche Programmiertechniken von der C++-Standardbibliothek nicht nur verwendet, sondern auch stark unterstützt.

Die in diesem Abschnitt diskutierte Metaprogrammierung bekommt steigendes Gewicht. Die Boost- und die C++-Standardbibliothek basieren großenteils darauf. Es geht darum, schon zur Compilationszeit abhängig vom Typ oder bestimmten Eigenschaften Entscheidungen zu treffen, etwa welcher Algorithmus gewählt werden soll. Diese Typinformationen sind in sogenannten Traits-Klassen festgelegt. Traits heißt Eigenschaft oder Merkmal. Der C++-Standard unterstützt Metaprogrammierung durch Bereitstellung von Templates für Typinformationen. Mehr dazu finden Sie unten in Abschnitt 13.5.

Das obige Beispiel zeigt, dass nicht nur mit constexpr, sondern auch mit Typen schon zur Compilationszeit gerechnet werden kann. Schleifen werden beim Rechnen mit Typen stets mit einer Rekursion realisiert. Menschen mit Lisp- oder Prolog-Erfahrung wird das bekannt vorkommen.

Image

Übungen

13.1 Schreiben Sie mithilfe der Template-Metaprogrammierung eine Template-Struktur Fakultaet, mit der die Fakultät n! = 1·2·3 . . . n einer int-Zahl n ≥ 0 zur Compilationszeit berechnet werden kann. 0! ist dabei als 1 definiert. Das main()-Programm soll 8!, d.h. 40320, ausgeben, etwa wie hier gezeigt:

int main() { cout << Fakultaet<8>::wert << ’\n’; }

13.2 Schreiben Sie mithilfe der Template-Metaprogrammierung eine Template-Klasse namens Fibonacci, mit der Fibonacci-Zahlen zur Compilationszeit berechnet werden können. Eine Fibonacci-Zahl f ist die Summe ihrer beiden Vorgänger, also fn = fn–1 + fn–2. Dabei seien f0 = 0 und f1 = 1. Das main()-Programm soll zum Beispiel die elfte Fibonacci-Zahl ausgeben.

13.3 Schreiben Sie die Fibonacci-Funktion als constexpr-Funktion auf zwei Arten: a) rekursiv, b) iterativ.

13.4 Welche der zwei Lösungen der vorherigen Aufgabe ist effizienter und warum?

Image

13.2 Variadic Templates: Templates mit variabler Parameterzahl

Die Anzahl der Parameter von Funktionen und Operatoren wird Stelligkeit oder Arität genannt. Die Addition ist zweistellig, weil sie zwei Argumente benötigt. Bisher werden Templates in diesem Buch mit einer festen Anzahl von Parametern (Typen) definiert. Bei der Benutzung müssen die Typen entsprechend der Anzahl angegeben werden. Die Stelligkeit ist festgelegt. Das Stack-Template von Seite 296 hat die Stelligkeit zwei:

template<typename T, int maxSize> // zwei Parameter class SimpleStack { // ... Rest weggelassen };

Die C++-Standardbibliothek kann in vielen Teilen einfacher geschrieben werden, seitdem es Templates mit variabler Stelligkeit (englisch variadic templates) gibt. Ein Beispiel dafür ist die Bibliotheksklasse tuple (für Tupel) von Seite 837. Ein anderes Beispiel ist eine Funktion zum Ausdrucken aller Parameter, bei der unbekannt ist, mit wie vielen Argumenten sie aufgerufen werden wird. Für jede beliebige Anzahl von Parametern jeweils eine Funktion zu schreiben, ist nicht praktikabel. Aus diesem Grund gibt es in der Programmiersprache C die Funktion int printf(const char* format, ...), der eine beliebig lange Parameterliste übergeben werden kann. format ist der C-String, der die Formatierung steuert. Die drei Punkte heißen Ellipse. Ellipse bedeutet Auslassung. Die Punkte stehen für eine Folge von Parametern. So gibt der Aufruf printf("Wert = %.4f\n", 1.2345678); die Zeile »Wert = 1.2346« aus. Dabei meint %.4f\n, dass eine Float-Zahl mit vier Dezimalstellen nach dem Komma ausgegeben und dann eine neue Zeile begonnen wird. printf() gibt die Anzahl der ausgegebenen Zeichen zurück bzw. -1 bei einem Fehler. Die Funktion printf() ist jedoch nicht typsicher, das heißt, falsche Typen in der Parameterliste können nicht schon vom Compiler entdeckt werden.

Douglas Gregor hatte unter der Überschrift »Variadic Templates« einen Vorschlag, wie Ellipsen typsicher gestaltet werden können, entwickelt. Der Vorschlag wurde in den C++-Standard aufgenommen. Einen Übersichtsartikel finden Sie unter [GrJ].

13.2.1 Ablauf der Auswertung durch den Compiler

Wie Templates mit variabler Stelligkeit funktionieren, sei am Beispiel einer Funktion anzeigen() demonstriert, der beliebig viele Parameter zur Ausgabe mit cout << übergeben werden können. Der Compiler löst die beliebig vielen Parameter rekursiv auf – angewandte Template-Metaprogrammierung! Dabei prüft er, ob der Typ eines Parameters überhaupt zum Ausgabeoperator << passt. Die Rekursion kann mithilfe einer anderen Syntax vermieden werden – wie, ist Thema des Abschnitts 13.3.

Listing 13.3: Funktion mit variabler Parameteranzahl nach [GrJ]. Erklärung siehe Text. (cppbuch/k13/variadicTemplate/anzeigen.cpp)

#include <iostream> void anzeigen() { std::cout << ’\n’; } template <typename T, typename... Rest> void anzeigen(const T& obj, const Rest&... rest) { std::cout << obj << ’˽’; anzeigen(rest...); } int main() { anzeigen(1); anzeigen(2, "Hallo"); anzeigen("Text", 7.978353, 3); }

Wie Sie sehen, wird anzeigen() mit einem bis drei Parametern aufgerufen. Das entscheidende Element ist die Template-Definition:

Image       Die Ellipse ... nach typename bedeutet, dass an dieser Stelle null oder mehr Template-Parameter in Rest zusammengefasst werden. Der Parameter Rest wird »Template Parameter Pack« genannt. Wenn anzeigen() mit mehreren Parametern aufgerufen wird, wird der erste T zugeordnet, alle anderen dem Parameter Rest – daher der Name.

Image       In der Parameterliste der Funktion wird Rest fast wie ein normaler Template-Parameter verwendet. Der syntaktische Unterschied besteht nur darin, dass eine Ellipse ... folgt, um die Eigenschaft »Template Parameter Pack« zu markieren.

Image       Dasselbe gilt für den Funktionsaufruf anzeigen(rest...): Im Aufruf ist rest..., gekennzeichnet durch eine Ellipse, ein »Template Parameter Pack«. Dieser Aufruf hat einen Parameter weniger als die aufrufende Funktion!

Wie verarbeitet der Compiler die Anweisung anzeigen("Text", 7.978353, 3);? Die Abfolge in einzelnen Schritten ist:

Image       Zuerst wird eine Ausgabe für das erste Objekt, den C-String "Text", erzeugt. Der anschließende Aufruf anzeigen(rest...); ist nichts anderes als anzeigen(7.978353, 3); – es wird nur der Rest übergeben.

Image       Der Aufruf anzeigen(7.978353, 3); wird genauso behandelt; es wird 7.978353 ausgegeben und dann anzeigen(3); aufgerufen.

Image       anzeigen(3); resultiert in der Ausgabe von 3 und dem Aufruf anzeigen();.

Image       anzeigen(); ruft die Funktion ohne Parameter oben in der Datei auf. Diese Funktion beendet nur die laufende Zeile.

Der Compiler erzeugt aus dem Aufruf anzeigen("Text", 7.978353, 3); also die folgenden Funktionen aus dem Template:

void anzeigen(const char * const&, const double&, const int&); void anzeigen(const double&, const int&); void anzeigen(const int&);

Der Typ const char * const& bedeutet const-Referenz auf const char*. Die Funktion ohne Parameter (anzeigen()) ist schon vorhanden und wird daher nicht erzeugt. Sie beendet die Rekursion.

13.2.2 Anzahl der Parameter

Die Anzahl der Parameter kann mit sizeof...(parameterPack) ermittelt werden. Der sizeof...-Operator gibt die Anzahl der Typen bzw. der Argumente des Parameter-Packs zurück. Die obige Funktion anzeigen() könnte zur Demonstration erweitert werden:

13.2.3 Parameterexpansion

Es gibt mehrere Arten der Expansion, von denen hier drei genannt werden. Das Parameter-Pack heiße args. Es bestehe beispielsweise aus drei Parametern p1, p2 und p3.

Image       f(args...) wird zu f(p1, p2, p3)
Diese Art der Expansion haben Sie oben gesehen. Der Aufruf anzeigen(rest...) ist also gleichbedeutend mit anzeigen(p1, p2, p3).

Image       f(++args...) wird zu f(++p1, ++p2, ++p3)
Das folgende Beispiel zeigt die Wirkung dieser Art der Expansion im Vergleich zur vorherigen. Anstelle des ++-Operators kann ein anderer stehen.

Im Programm gibt es eine überladene Funktion ohne Parameter, die die rekursive Auswertung der Aufrufe durch den Compiler beendet. Hier ist sie zusätzlich hilfreich, weil sie die Anzeige mit \n beendet. Im Listing 13.6 sehen Sie, wie mit if constexpr die für den Rekursionsabbruch benötigte Funktion eingespart werden kann.

Image       f(&args...) wird zu f(&p1, &p2, &p3)

Das Programm nimmt die Adressen der Parameter. Das erlaubt es, die Werte der Parameter zu ändern. Hier werden sie verdoppelt.

if constexpr wertet nur eine Bedingung aus, die schon zur Compilationszeit bekannt ist. Hier bewirkt sie, dass es keinen Aufruf der Funktion verdoppeln() mit leerer Parameterliste geben kann. Damit entfällt die Notwendigkeit, eben diese Funktion zu definieren.

13.3 Fold-Expressions

Fold-Expressions (dt. etwa Faltungsausdrücke) sind Vereinfachungen für eine Folge von Parametern. Das Wort »fold« stammt aus der funktionalen Programmierung und bezieht sich auf eine Familie von Funktionen, die eine rekursive Datenstruktur analysieren. Zunächst sei ein Programm vorgestellt, das eine im Vergleich zu oben geänderte Funktion anzeigen() enthält. Sie kann im Prinzip beliebig viele Parameter haben. Im Aufruf sind es drei. Sie sehen sofort, dass dieses Programm ohne Rekursion auskommt und daher einfacher im Vergleich zum obigen ist.

Das Entscheidende ist die mit »Fold-Expression« markierte Zeile. Sie hat den Aufbau

(E ⊗ ... ⊗ P)

Dabei ist E ein Ausdruck, der kein zu expandierendes Parameter-Pack enthält (std::cout in der Zeile). ⊗ steht für einen binären Operator, oben ist es der Ausgabeoperator <<. Der binäre Operator kann in anderen Fällen nahezu jeder andere binäre Operator sein, wie etwa +, *, ==, += usw. Auch der Kommaoperator , gehört dazu. P steht für einen Ausdruck, der das zu expandierende Parameter-Pack enthält. Die runden Klammern müssen sein! Wenn genau drei Parameter vorhanden sind, wird so eine Zeile vom Compiler wie folgt expandiert:

((E ⊗ p1) ⊗ p2) ⊗ p3

wobei p1, p2 usw. die einzelnen Teile des Parameter-Packs sind, auf die gegebenenfalls eine in P beschriebene Operation angewendet wird. Anstelle der Funktion kann auch ein unärer Operator wie zum Beispiel ++ verwendet werden, wie in Abschnitt 13.2.3 bei der Parameterexpansion gesehen. Ein Fold-Expression

(E ⊗ ... ⊗ f(args)) würde daher ((E ⊗ f(p1)) ⊗ f(p2)) ⊗ f(p3) ergeben.
Image

Hinweis

Im Folgenden wird zur Vereinfachung der Beschreibung ein einfaches Parameter-Pack ohne Funktion oder Operator angenommen.

Image

Entsprechendes gilt für mehr oder weniger Parameter. Bei vier Parametern ergäbe sich

(((E ⊗ p1) ⊗ p2) ⊗ p3) ⊗ p4

Die Fold-Expression-Zeile des obigen Programms wird daher zu

((std::cout << einString) << "textliteral") << 7.978353 << ’\n’;

ausgewertet.

13.3.1 Weitere Varianten

Um nicht eine unbestimmte Anzahl von Klammern abbilden zu müssen, wird hier die allgemeine Expansion der Fold-Expressions mit vier Parametern beschrieben. Im Einzelfall ist natürlich die tatsächliche Anzahl der Parameter maßgebend.

Image       (P...E) wird zu p1(p2(p3(p4E)))
Im Vergleich zu oben sind E und P vertauscht und die Auswertung geschieht von rechts statt von links. Ein Beispiel:

Statt anfangswert darf kein Ausdruck stehen, der eine höhere Priorität enthält, wie etwa 3*anfangswert (Punktrechnung geht vor Strichrechnung). Wenn dieser Ausdruck geklammert wird, also (3*anfangswert), gibt es kein Problem.

Image       (P...) wird zu p1(p2(p3p4))
Wenn kein Anfangswert gebraucht wird, vereinfacht sich das vorherige Beispiel.

Image       (...P) wird zu ((p1p2)p3)p4
Bei einer Summe ist es egal, ob von rechts oder von links aufsummiert wird. Deswegen lassen sich hier P und ... vertauschen. Bei anderen Problemstellungen ist das nicht unbedingt möglich.

13.3.2 Fold-Expression mit Kommaoperator

Der Kommaoperator in einem Fold-Expression bewirkt, dass eine Operation auf jeden Parameter angewendet wird. Das heißt, (f(args) , ...) wird zu f(p1), f(p2), f(p3) usw. Damit lässt sich das Listing 13.6 von Seite 516 einfacher schreiben:

Man kann ohne die Funktion verdoppeln() auskommen, wenn die Operation »Multiplikation mit 2« direkt in die Funktion integriert wird:

Dabei sind die Klammern zwingend, weil der Kommaoperator die niedrigste Priorität hat. Eine weitere Alternative ist die Übergabe einer Lambda-Funktion:

13.4 Klassen-Template mit variabler Stelligkeit

Templates mit variabler Stelligkeit sind nicht nur für Funktionen, sondern auch für Klassen möglich. Das wohl bekannteste Klassen-Template mit variabler Stelligkeit ist die Klasse tuple der Standardbibliothek (siehe Seite 837). Listing 13.14 zeigt eine vergleichsweise einfache Struktur. Die Auswertung geschieht zur Compilationszeit:

13.5 Type Traits

C++ unterstützt die Template-Metaprogrammierung, weil sich mit ihr viele Aufgaben schon zur Compilationszeit erledigen lassen. Unter anderem ist es möglich, aus mehreren Algorithmen den effizientesten zu wählen, wie unten gezeigt wird. Zur Unterstützung der C++-Standardbibliothek und auch selbstgeschriebener Programme bietet C++ sogenannte Type Traits an [ISOC++, meta]. Das sind öffentliche Klassen (struct), die bestimmte Typinformationen liefern oder mit deren Hilfe Typen umgewandelt werden können. Sie sind im Header <type_traits> definiert. Type-Traits können vorteilhaft in Concepts verwendet werden, das Thema des nächsten Abschnitts 13.6.

Image

Hinweis

Ziel ist, die Wirkungsweise der wichtigsten Type Traits kennenzulernen. Auf dieser Basis können dann Quellen wie [ISOC++] ergänzend hinzugezogen werden. Deshalb wird eine Auswahl der Type Traits dargestellt, oft mit beispielhaften Anwendungen.

Image

So gibt etwa is_class an, ob ein Typ eine Klasse ist. Das Programm in Listing 13.15 gibt false, true, true aus, denn int ist kein Klassentyp, X und vector<double> sind es jedoch schon.

13.5.1 Wie funktionieren Type Traits? — ein Beispiel

Um zu zeigen, wie Type Traits funktionieren, wird unten eine Klasse has_sort_function< Container> definiert, die erkennt, ob ein Container eine Funktion sort() hat. Ein vector-Objekt kann wegen des wahlfreien Zugriffs mit dem allgemeinen Sortieralgorithmus std::sort() sortiert werden. Ein list-Objekt ist nicht damit sortierbar, weswegen die Klasse list eine spezialisierte Funktion zum Sortieren enthält. Ein Algorithmus, der entscheiden soll, ob eine containerspezifische sort()-Funktion oder std::sort() aufzurufen ist, benötigt ein Entscheidungskriterium. Dieses bietet has_sort_function<Container>. Für die Realisierung werden die Typen true_type und false_type benutzt, die wie folgt definiert sind:

Von diesem Klassentemplate werden die Typnamen std::true_type und std::false_type abgeleitet:

template <bool B> using bool_constant = integral_constant<bool, B>; using true_type = bool_constant<true>; using false_type = bool_constant<false>;

integral_constant und sein Alias bool_constant werden für die Definition verschiedener Type Traits genutzt. v ist kein Typ, sondern ein Wertparameter des Typs T. Solche Parameter müssen von einem integralen Typ (bool, char, int usw.) oder ein Zeiger sein, andernfalls ist das Template nicht übersetzbar. Damit hat true_type::value den Wert true und false_type::value den Wert false. Das Template has_sort_function<T> erbt von false_type:

template <typename T, typename = void> // Template 1 struct has_sort_function : std::false_type {};

Der zweite Template-Parameter hat keinen Namen, weil er nicht benutzt wird. Sein Typ ist void, weil das auch der Rückgabetyp der Funktion sort() ist.

Die folgende Spezialisierung ersetzt den zweiten Template-Parameter durch den Typ der sort()-Funktion des Containers – die dazu aber vorhanden sein muss! Der Typ ist natürlich auch void, aber er wird aus der Funktion T::sort() abgeleitet:

template <typename T> // Template 2 struct has_sort_function<T, decltype(std::declval<T>().sort())> : std::true_type {};

Bei dieser Formulierung müssen keine Kenntnisse über den Konstruktor von T vorliegen. Wenn bekannt ist, dass zum Beispiel der Standardkonstruktor T() existiert, kann man kürzer schreiben:

struct has_sort_function<T, decltype(T().sort())> // nur falls T() existiert : std::true_type {};

Natürlich könnte man ohne true_type bzw. false_type auskommen, wenn ein entsprechender Wahrheitswert value direkt in den Klassen definiert würde. In den Implementierungen der Type Traits der C++-Standardbibliothek werden true_type bzw. false_type jedoch häufig verwendet, weswegen hier ebenso vorgegangen wird. Wenn der Compiler versucht, das Template zu instanziieren, gibt es zwei Fälle:

1.      Der Typ T hat keine sort()-Funktion.
Die Instanziierung des zweiten Templates scheitert, sodass das erste Template zur Geltung kommt. Weil es von std::false_type erbt, ist has_sort_function<T>::value == false. Das Attribut value wird von std::false_type geerbt (siehe oben).

2.      Der Typ T besitzt eine sort()-Funktion.
In diesem Fall könnten beide Templates instanziiert werden, weil der zweite Template-Parameter bei Template 1 und Template 2 den Typ void ergibt. Das zweite Template ist aber das spezialisierte und wird deswegen ausgewählt. Weil es von std::true_type erbt, gilt has_sort_function<T>::value == true.

Dass im ersten Fall die Substitution der Template-Argumente fehlschlägt, ist also kein Fehler, solange es ein anderes passendes Template gibt. Der englische Ausdruck für diese Technik ist substitution failure is not an error, oft mit SFINAE abgekürzt.

Zur Compilationszeit kann somit entschieden werden, ob ein Typ eine sort()-Funktion hat. In dem Listing 13.17 wird das ausgenutzt, um zur Compilationszeit den richtigen Sortieralgorithmus auszuwählen.

Die Hilfsfunktionen showContainer() zeigen einen Container oder einen Range auf dem Bildschirm an. Die Header-Datei liegt im Verzeichnis cppbuch/include der Beispiele und ist wie folgt definiert:

Der Typ Container&& spart gegebenenfalls die Kopie eines temporären Containers ein. decltype(auto) ermittelt den Typ und erhält dabei die Referenz. Nur auto& zu schreiben passt nicht für jede Art von Container, zum Beispiel vector<bool>, weil die Adresse eines Bits nicht genommen werden kann. Die zweite Funktion ist für den Fall, dass nur ein Teil des Containers zur Anzeige gebracht werden soll. Die Iteratoren kennzeichnen Anfang und Ende des anzuzeigenden Bereichs. Beim Aufruf muss sichergestellt sein, dass i2 mit der ++-Operation von i1 aus erreichbar ist. Eine einfache Prüfung der Art assert(i1 <= i2) funktioniert bei einem Vektor, genauer: bei Random-Access-Iteratoren, nicht aber, wenn der Container zum Beispiel ein set oder eine Liste ist.

13.5.2 Abfrage von Eigenschaften

Es gibt viele Type Traits zur Abfrage von Eigenschaften eines Typs. Bevor sie genannt werden, wird zunächst am Beispiel von is_arithmetic gezeigt, wie ein Type Trait zur Abfrage einer Typeigenschaft konkret angewendet wird. Im folgenden Programm wird die Summe aller Elemente eines Containers berechnet. Dabei können die Container verschiedenen Typs sein, etwa list oder vector, ebenso wie die Elemente, etwa int oder double. Die Summe kann sinnvoll nur aus Zahlen berechnet werden. Mit is_arithmetic kann eine mathematische Funktion sicherstellen, dass der Aufrufer den richtigen Datentyp verwendet. Dabei wird ausgenutzt, dass die Container der Standardbibliothek den Datentyp der Elemente mit Container::value_type zur Verfügung stellen:

Die folgende Ergänzung des Programms ist nicht übersetzbar, weil mit Strings nicht gerechnet werden kann:

const std::list<std::string> strings{"abc", "def"}; std::cout << summe(strings) << ’\n’;

std::is_arithmetic<typename Container::value_type>::value ist nur dann true, wenn mit den Containerelementen gerechnet werden kann. Ohne die static_assert-Anweisung würde die Funktion übersetzt werden und im letzten Fall ein falsches Ergebnis liefern, nämlich den String »abcdef«. Die folgenden Templates werden auf ähnliche Weise wie is_arithmetic benutzt. Das heißt, es wird die boolesche Konstante value ausgewertet, etwa std::is_integral<int>::value. Diese Type Traits erben die Konstante value von der oben erwähnten Hilfsklasse integral_constant, also von true_type bzw. false_type.

Image

Abkürzende Schreibweise mit _v

Für alle C++-Traits gilt, dass ::value nicht geschrieben werden muss, wenn an den Traits-Namen _v angehängt wird. Das heißt, dass statt std::is_arithmetic<T>::value kürzer std::is_arithmetic_v<T> geschrieben werden kann. Und is_class<T>::value wird dann das kürzere is_class_v<T>.

Image

In diesem Abschnitt geht es um das Verständnis, wie Type Traits funktionieren, nicht um eine vollständige (platzraubende) Darstellung. Auch sind Type Traits hauptsächlich für die Entwicklung von Bibliotheken wichtig, nicht für Programme, die Bibliotheken nutzen. Aus diesen Gründen wird im Folgenden nur eine kleine Auswahl der Type Traits vorgestellt.

Listing 13.20: Auswahl einiger Traits aus [ISOC++]

// Einfache Typ-Kategorie template <class T> struct is_void; // void template <class T> struct is_null_pointer; // nullptr_t (Typ des Nullpointers) template <class T> struct is_integral; // Ganzzahltyp template <class T> struct is_floating_point; // Gleitkommazahltyp template <class T> struct is_array; // C-Array-Typ template <class T> struct is_pointer; // »roher« Zeiger template <class T> struct is_lvalue_reference; // Referenz auf L-Wert template <class T> struct is_rvalue_reference; // Referenz auf R-Wert template <class T> struct is_enum; // Aufzählungstyp template <class T> struct is_class; // Klasse template <class T> struct is_function; // Funktionstyp // zusammengesetzte Typ-Kategorie template <class T> struct is_reference; // L- oder R-Wert template <class T> struct is_arithmetic; // arithmetischer Typ template <class T> struct is_fundamental; // Grunddatentyp (alle ganzzahligen Typen, // alle Gleitkommatypen, alle Zeichentypen // und bool) // Typeigenschaften template <class B, class A> struct is_base_of; // A ist B oder von B abgeleitet template <class T> struct is_const; // const-Typ template <class T, class U> struct is_convertible; // T kann in U umgewandelt werden template <class T> struct is_polymorphic; // Klasse mit virtueller Methode template <class T> struct is_abstract; // abstrakte Klasse template <class T> struct is_final; // Klasse, von der nicht abgeleitet werden kann template <class T, class U> struct is_same; // T und U sind gleich template <class T> struct is_signed; // vorzeichenbehaftet template <class T> struct is_unsigned; // nicht vorzeichenbehaftet

Die Datei cppbuch/k13/typetraits/typrelation.cpp (nicht abgedruckt) zeigt am Beispiel von is_convertible, is_same und is_base_of, wie eine Anwendung aussehen kann.

13.5.3 Abfrage numerischer Eigenschaften

template <class T> struct alignment_of fragt die Ausrichtung an Speichergrenzen ab. Für einen Typ X gilt std::alignment_of<X>::value == alignof(X). Ein Beispiel finden Sie in der Datei cppbuch/k13/typetraits/alignof.cpp.

std::rank<X>::value gibt die Anzahl der Dimensionen zurück, wenn X ein Arraytyp ist, andernfalls 0. Auszug aus cppbuch/k13/typetraits/rank.cpp:

std::cout << std::rank_v<int[2][3][4]> << ’\n’; // 3

Hier wird das abkürzende _v verwendet, statt ::value zu schreiben. std::extent<X, N = 0>::value gibt die Anzahl der Elemente der Dimension N des Arraytyps X an, andernfalls ist der Wert 0. Auszug aus cppbuch/k13/typetraits/extent.cpp:

// Ausgabe der Grenze der N-ten Dimension (Zählung ab 0) std::cout << std::extent_v<int[2][3][4]> << ’\n’; // 2 std::cout << std::extent_v<int[2][3][4], 1> << ’\n’; // 3 std::cout << std::extent_v<int[2][3][4], 2> << ’\n’; // 4
13.5.4 Typumwandlungen

Diese Klassen haben ein Attribut type, das den gewünschten Typ repräsentiert.

Image       remove_reference entfernt die Referenz von einem Typ. Für eine Klasse X gilt:
std::is_same_v<X, std::remove_reference<X&>::type> == true
Wie funktioniert remove_reference? Das wird klar, wenn Sie eine mögliche Implementation ansehen:

template<typename T> struct remove_reference { using type = T; }; // Spezialisierungen: template<typename T> struct remove_reference<T&> { using type = T; }; template<typename T> struct remove_reference<T&&> { using type = T; };

Ob das Templateargument von remove_reference T, T& oder T&& ist, der deklarierte Typ type ist immer T.

Image       Auf ähnliche Weise funktioniert remove_const. Damit gilt:
std::remove_const<const X>::type ist X.
std::remove_const<int* const>::type ist int*.
std::remove_const<const int*>::type ist const int*,
also dasselbe. In diesem Fall ist der Zeiger selbst nicht const, er verweist nur auf nicht veränderliche Zahlen.

Image

Abkürzende Schreibweise mit _t

Analog zur Abkürzung mit _v für ::value kann _t für ::type genommen werden. Statt remove_const<int* const>::type kann daher kürzer remove_const_t<int* const> geschrieben werden usw.

Image

13.5.5 Auswahl weiterer Traits

Die Auswahl begründet sich auf der häufigeren Verwendung. So wird decay in der möglichen Implementation von apply() benutzt [ISOC++, intseq.general].

decay

decay liefert den zugrunde liegenden Typ durch Entfernen der Referenz- und der const-Eigenschaft. Damit gilt zum Beispiel:

std::decay_t<const X&> ist X std::decay_t<const X> ist X std::decay_t<X&> ist X std::decay_t<X[]> ist X*

enable_if

Ein einfaches Template zur Steuerung der Übersetzung ist enable_if. Es ist etwa so definiert:

template<bool B, class T = void> struct enable_if {}; template<class T> struct enable_if<true, T> { using type = T; };

Die Wirkung: Falls sich der erste Template-Parameter x zur Compilationszeit als true erweist, kann mit enable_if<x, T>::type der Typ T definiert werden. Andernfalls existiert enable_if<x, T>::type nicht. Damit kann enable_if für die oben genannte SFINAE-Technik verwendet werden. Eine andere Möglichkeit ist das vollständige Scheitern der Übersetzung, wenn eine Bedingung nicht erfüllt wird. Dazu wird die Funktion summe() des obigen Listings 13.19 (Seite 525) wie folgt modifiziert:

1.      Die static_assert-Anweisung wird gestrichen.

2.      Der Rückgabetyp wird mithilfe von enable_if so definiert, dass er nur existiert, wenn die Elemente des Containers von einem arithmetischen Typ (Zahlen) sind. Wenn nicht, gibt es keinen Rückgabetyp und die Übersetzung schlägt fehl.

Image

Tipp

Es ist einfacher, statt enable_if ein Concept zu nehmen. Concepts sind das Thema des nächsten Abschnitts 13.6.

Image

conditional

conditional_t<true, T, F> ist T. conditional_t<false, T, F> ist F. conditional ähnelt damit enable_if.

char_traits

Character Traits definieren Typen und Funktionen von Zeichentypen. Der Header string muss inkludiert werden. Hier seien nur zwei Funktionen erwähnt. eof() gibt den EOF-Wert (End Of File) für den betreffenden Zeichentyp zurück. Und die Funktion length(str) berechnet die Länge des C-Strings str. Im Unterschied zur Funktion strlen() (Header <cstring>) ist sie jedoch constexpr. Die Datei cppbuch/k13/typetraits/chartraits.cpp zeigt die Anwendung beider Funktionen.

Weitere Traits und Einzelheiten finden Sie in [VJG] und [ISOC++, meta]. Der Abschnitt 28.1.1 dieses Buchs zeigt die Anwendung von Traits mit Iteratoren.

13.6 Concepts

Wenn möglich, soll man schon zur Compilationszeit die Gültigkeit bestimmter Annahmen sicherstellen. In diesem Abschnitt geht es nicht um Annahmen über Werte, die zur Compilationszeit mit static_assert() überprüft werden können, sondern über die Zusicherung bestimmter Eigenschaften von Datentypen. Für den Compiler sind solche Zusicherungen hilfreich. Wenn eine notwendige Eigenschaft fehlt, kann die Übersetzung sofort abgebrochen werden. Die Vorteile: Zeitersparnis, weil die Übersetzung bei einem Fehler schneller scheitert, und bessere Verständlichkeit der Fehlermeldungen, weil die nicht gelungene Zusicherung dokumentiert und der Fehler sofort klar wird. Überdies wird die Länge der Fehlermeldungen deutlich reduziert. Für diese Art von Zusicherungen gibt es die Concepts. Mangels einer guten Übersetzung verwende ich nur den englischen Begriff.

Image

Concept

Ein Concept ist eine Menge von Anforderungen an einen Template-Datentypen, der ein Name zugeordnet wird. Das Concept ist damit Teil der Template-Schnittstelle. Es wird zur Compilationszeit ausgewertet.

Image

Am besten sieht man das an Beispielen, zuerst ohne und dann zum Vergleich mit einem Concept.

Beispiel: Summenfunktion nur für numerische Typen

Listing 13.22 zeigt eine Funktion summe() zum Addieren aller Zahlen eines Vektors. Die Elemente des Vektors sind sinnvollerweise von einem der Datentypen int oder double und Verwandten dieser Typen (long, float usw.).

Listing 13.23 zeigt die Anwendung der Summenfunktion.

So weit, so gut. Was ist aber, wenn der Vektor aus Strings besteht? Zum Beispiel:

std::vector<std::string> container {"eins", "zwei", "drei"};

Die Funktion summe() würde einfach »einszweidrei« zurückgeben. Mit einer Summe hat das nichts zu tun; es ist nur eine Verkettung. Damit der Compiler solche Fälle erkennen kann, braucht es ein Concept, das garantiert, dass nur Zahlen addiert werden. In Listing 13.24 wird es Zahl genannt. Das folgende Concept erfordert (englisch requires) erstens, dass die Operation + möglich ist. Da das auch für Strings gilt, wird auch die Operation - für die Subtraktion erlaubt – die ist auf Zahlen, aber nicht auf Strings anwendbar.

Der Versuch, Strings zu addieren, würde damit sofort zu einer Fehlermeldung des Compilers führen, etwa dieser Art (leicht gekürzt):

main.cpp:11:45: Fehler: Funktion T summe(const std::vector<T>&) kann nicht aufgerufen werden 11 | std::cout << "Summe˽=˽" << summe(container) << ’\n’; Anmerkung: constraints not satisfied

In die requires-Klausel werden Operationen hineingeschrieben, die mit dem Typ T übersetzbar sind. Die obige requires-Klausel ist nicht vollständig, weil andere numerische Operationen fehlen. Die Funktion summe() würde hier auch für eine Klasse funktionieren, die zwar + und - implementiert, aber nicht / und *. Für unser Beispiel reicht die zusätzliche Angabe der Subtraktion. In Listing 13.24 sehen Sie, wie Sie selbst ein Concept schreiben können. Besser ist es, vorhandene Type Traits der C++-Standardbibliothek zu nutzen:

Dateien zu diesem Beispiel finden Sie im Verzeichnis cppbuch/k13/concepts/summe der downloadbaren Beispiele.

Beispiel: Wahlfreien Zugriff für Sortierfunktion garantieren

Listing 13.26 zeigt eine Funktion sortieren() zum Sortieren eines Containers.

Es wird nicht die Bibliotheksfunktion std::sort() verwendet, weil sie vermutlich irgendwann mit einem Concept ausgestattet sein wird und dann der Unterschied ohne und mit Concept nicht erkennbar wäre. Auch ist die Performance für den Zweck dieses Beispiels egal. Einige Anforderungen an die Elemente des zu sortierenden Containers sind sofort erkennbar, zum Beispiel, dass ein Vergleich mit > und eine Zuweisung möglich sein muss (Zeilen 6, 8 und 9). Hier soll es jedoch nur um die Anforderungen an den zu sortierenden Container gehen. Für den Algorithmus ist entscheidend, dass auf die Elemente mit dem []-Operator zugegriffen wird und dass der Zugriff wahlfrei (englisch random access) ist. Das heißt, dem []-Operator wird direkt die Adresse des Elements mitgegeben (Zeilen 6 bis 9). Das Listing 13.27 zeigt die Anwendung der Sortierfunktion.

Was ist aber, wenn der Container kein Vektor ist, sondern eine Liste? Zum Beispiel:

std::list<int> container {9, 2, 3, 4, 5, 10};

Es würde eine Fehlermeldung geben, weil std::list keinen []-Operator hat. Ein []-Operator allein genügt aber nicht, er muss auch einen wahlfreien Zugriff ermöglichen. Das ist beim Vektor der Fall, nicht aber beim map-Container. Der wird nur aus diesem Grund hier vorab erwähnt; erst in Abschnitt 27.4.1 wird er ausführlich beschrieben. Ohne Concept erkennt der Compiler std::map nicht als fehlerhaft bezüglich der Sortierfunktion.

std::map<int, int> container { {1, 0}, {2, 9} }; sortieren(container); // keine Fehlermeldung des Compilers!

Daraus folgt, dass die Eigenschaft »wahlfreier Zugriff« von einem Concept gesichert werden sollte. Dass man eine Zahl auf einen Iterator für wahlfreien Zugriff addieren kann, um eine andere Position zu erhalten, wissen Sie aus Abschnitt 10.2. Das kann man sich zunutze machen, indem im Concept eine entsprechende Rechenoperation definiert wird. Nur wenn diese übersetzbar ist, ist das Concept gültig. Listing 13.28 zeigt das Concept und seine Anwendung.

Die Wirkung der Parameterliste Sortierbar auto& c kann man sich so vorstellen: Erst ermittelt der Compiler mit auto den Typ des Containers c anhand des Aufrufs der Funktion. Dann prüft er, ob der ermittelte Typ die Anforderungen des Concepts Sortierbar erfüllt.

Dateien zu diesem Beispiel finden Sie im Verzeichnis cppbuch/k13/concepts/sortieren der downloadbaren Beispiele. Da Sie nun wissen, wie Concepts im Prinzip funktionieren, sollten Sie möglichst wenige selbst schreiben, sondern sich auf die vorhandenen verlassen. So können Sie Sortierbar streichen und durch das existierende sortable ersetzen. Es müssen nur der Parameter der Funktion sortieren() und der Header angepasst werden:

#include <iterator> // ... void sortieren(std::sortable auto& c) { // Rest wie oben }

Vordefinierte C++-Concepts

Es geht oben und hier um das Verständnis, wie Concepts funktionieren, nicht um eine vollständige Darstellung. Auch sind Concepts wie Type Traits hauptsächlich für die Entwicklung von Bibliotheken wichtig, weniger für Programme, die Bibliotheken nutzen. Deswegen wird im Folgenden nur kurz auf die Konstruktion von Concepts mit Type Traits eingegangen und es werden wenige weitere vorgestellt. Der Header ist <concepts>, wenn nicht anders angegeben. Die in C++ vorgesehenen Concepts leiten sich oft aus Type Traits ab. So könnte same_as etwa so mit is_same<T, U> aus Tabelle 13.20 (Seite 526) implementiert werden:

template<class T, class U> concept same_as = is_same_v<T, U>; // T ist gleich U

is_same_v<T, U> ist dabei wie üblich dasselbe wie is_same<T, U>::value. Das Concept signed_integral für eine vorzeichenbehaftete Ganzzahl wird mit dem Concept für eine (negative oder positive) Ganzzahl gebildet:

template<class T> concept integral = is_integral_v<T>; // Ganzzahl template<class T> concept signed_integral = integral<T> && is_signed_v<T>; // Ganzzahl mit Vorzeichen

Dies gilt auch für char, weil ein char-Zeichen in C++ eine 1-Byte-Ganzzahl ist. Die Nächsten sind selbsterklärend, besonders wenn die zugrunde liegenden Type Traits bekannt sind.

template<class T> concept unsigned_integral; template<class T> concept floating_point; template<class Derived, class Base> concept derived_from; template<class From, class To> concept convertible_to;

Andere Concepts beziehen sich auf die Konstruktion oder Zuweisung von Objekten:

template<class T> concept copy_constructible; // T hat Kopierkonstruktor template<class T> concept move_constructible; // T hat Bewegungskonstruktor template<class LHS, class RHS> concept assignable_from; // Einem Objekt des Typs LHS // kann eines des Typs RHS zugewiesen werden.

LHS und RHS stehen für die linke bzw. rechte Seite einer Zuweisung (englisch left hand side bzw. right hand side). Es gibt auch Concepts für Iteratoren (Header <iterator>). Oben bei der Sortierfunktion wird der wahlfreie Zugriff erwähnt. Das Concept dazu ist:

template<class I> concept random_access_iterator;

Damit hätte das Concept Sortierbar in Listing 13.28 so definiert werden können:

template<typename T> concept Sortierbar = random_access_iterator<typename T::iterator>;

Allerdings ist das oben schon erwähnte Concept sortable umfassender und deshalb zu bevorzugen. Weitere Concepts und Einzelheiten dazu finden Sie in [ISOC++, concepts.syn] und [ISOC++, iterator.concepts].