C++ Tutorial

Concepts

Ein Concept definiert eine Bedingung, unter der ein Funktions-, Klassen- oder Lambda-Template (im Folgenden vereinfacht nur Template genannt) instanziiert wird. Ebenfalls kann ein Concept dazu eingesetzt werden, eine non-template-Funktion (typischerweise eine Methode eines Klassentemplates) in Abhängigkeit von einer Bedingung zu definieren.

Programmtechnisch gesehen ist ein Concept ein während der Übersetzung des Programms auszuwertendes Predicate, also ein Ausdruck, dessen Auswertung entweder true oder false zurückliefert.

requires-Anweisung

Um eine Bedingung für die Instanziierung eines Templates zu definieren, folgt nach der spitzen Klammer mit den formalen Datentypen das Schlüsselwort requires und anschließend das Concept.

template <typename T> requires CONCEPT
class Data
{...};

Auf das nach requires stehende CONCEPT kommen wir gleich zu sprechen.

Bei Funktions- oder Lambda-Templates kann das Concept alternativ nach den Funktionsparametern angegeben werden.

template <typename T>
bool IsEqual(const T& val1, const T& val2) requires CONCEPT
{...}

Oder als Lambda-Template

auto IsEqual = [] <typename T>
             (const T& val1, const T& val2) requires CONCEPT
             {...};

Ist ein Template von mehreren Concepts abhängig, können diese mit den Operatoren && oder || verknüpft werden.

template <typename T> requires CONCEPT1 || CONCEPT2
class Data
{...};

Im einfachsten Fall wird ein in der Standardbibliothek vordefiniertes Concept eingesetzt, um die Anforderungen an ein Template zu definieren. So liefert z.B. das Concept std::floating_point<T> true zurück, wenn T ein Gleitkomma-Datentyp ist oder das Concept std::same_as<T1,T2>, wenn T1 und T2 denselben Datentyp besitzen. Wenn Sie eines der vordefinierten Concepts einsetzen, ist die Header-Datei <concepts> einzubinden.

Da die Standardbibliothek sehr viele vordefinierte Concepts enthält, sei an dieser Stelle auf die Übersicht auf https://en.cppreference.com unter dem Stichwort Concepts verwiesen.

Sehen wir uns ein Beispiel für den Einsatz eines der vordefinierten Concepts an. Vorgegeben sein ein Funktionstemplate IsEqual(), welches zwei übergebene Daten auf Gleichheit prüft.

#include <print>
template <typename T>
bool IsEqual(const T& val1, const T& val2)
{
   return val1 == val2;
}
int main()
{
   float fval1 = 10.3f;
   for (float fval2 = 10.0f; fval2 < 10.6f; fval2 += 0.1f)
      std::println("{} == {}: {}", fval1, fval2,
                                   IsEqual(fval1, fval2));
}

Wenn Sie dieses Programm laufen lassen, werden Sie folgende Ausgabe erhalten:

10.3 == 10: false
10.3 == 10.1: false
10.3 == 10.200001: false
10.3 == 10.300001: false
10.3 == 10.400002: false
10.3 == 10.500002: false

Aufgrund der internen Darstellung von Gleitkomma-Daten kommt es hier zu Rundungsfehlern bei der Addition mit der Schleifenvariable fval2. Dies ist auch der Grund, warum Gleitkomma-Daten nie ohne besondere Vorkehrung auf Gleichheit abgeprüft werden sollten.

Nun sollen zwei Gleitkomma-Daten dann gleich sein, wenn deren Differenz kleiner als ein bestimmter Wert ist. Dazu wird ein zweites Funktionstemplate IsEqual() definiert, das nur dann instanziiert wird, wenn die übergebenen Daten Gleitkomma-Daten sind. Das entsprechende Concept dazu ist in der Standardbibliothek unter dem Namen floating_point<T> definiert.

#include <print>
#include <cmath>
template <typename T> requires std::floating_point<T>
bool IsEqual(const T& val1, const T& val2)
{
   return fabs(val1 - val2) < 0.0001f;
}
template <typename T>
bool IsEqual(const T& val1, const T& val2)
{
   return val1 == val2;
}
int main()
{
   float fval1 = 10.3f;
   for (float fval2 = 10.0f; fval2 < 10.6f; fval2 += 0.1f)
      std::println("{} == {}: {}", fval1, fval2,
                                   IsEqual(fval1, fval2));
}

Da nun für Gleitkomma-Daten das Template mit dem Concept instanziiert wird, ergibt sich folgende Ausgabe:

10.3 == 10: false
10.3 == 10.1: false
10.3 == 10.200001: false
10.3 == 10.300001: true
10.3 == 10.400002: false
10.3 == 10.500002: false

Das gleiche Verhalten hätte auch durch eine entsprechende Spezialisierung des Funktionstemplates erreicht werden können, jedoch wären dann drei Funktionstemplates notwendig, nämlich eines für jeden Gleitkomma-Datentyp.

concept-Anweisung

Die concept-Anweisung dient zur Definition von eigenen Concepts:

template <typename T>
concept NAME = requires CONDITION;

NAME ist der Bezeichner des Concepts und CONDITION das Predicate mit der Bedingung. Auch hier können mehrere Bedingungen mit den Operatoren && und || verknüpft werden.

Nehmen wir an, wir haben folgende Klasse Complex zum Rechnen mit komplexen Zahlen entwickelt:

1: template <typename T>
2: class Complex
3: {
4:    T real;
5:    T imag;
6: public:
7:    Complex(T r, T i): real(r),imag(i)
8:    {}
9:    ...
10: };
11:
12: int main()
13: {
14:    Complex obj1(1., 2.);      // T ist double
15:    Complex obj2(1.f, 2.f);    // T ist float
16: }

Anforderung an Datentyp

Aufgrund der Rechenungenauigkeit von float-Daten sollen von der Klasse Complex nur Objekte instanziiert werden können, wenn der formale Datentyp T ein double oder long double ist. Zum Prüfen von Datentypen enthält die Standardbibliothek das Concept same_as<T1,T2>. Für unseren Fall ist mit same_as<> einmal der Datentyp double und einmal der Datentyp long double abzuprüfen, wobei die Ergebnisse verodert werden. Zum Schluss ist das Concept mit der requires-Anweisung zum Complex-Template hinzuzufügen.

1: template <typename T>
2: concept CheckDTyp = std::same_as<T, double> ||
3:                     std::same_as<T, long double>;
4:
5: template <typename T> requires CheckDTyp<T>
6: class Complex
7: {...};

Anforderung an Operationen

Mit Concepts können nicht nur Datentypen geprüft werden, sondern auch Operationen, die ein Datentyp zur Verfügung stellen muss.

template <typename T>concept CheckOp = requires (T op) { op OPERAND op; };

Angenommen Objekte unserer Klasse Complex sollen sortiert werden. Da auch andere Objekte sortiert werden sollen, schreiben wir ein Funktionstemplate Sort(), das die Daten mit dem Operator < vergleicht. D.h. Sort() stellt die Anforderung an die zu sortierenden Daten, dass die Vergleichsoperation < definiert ist.

Das entsprechende Concept für diese Anforderung sieht wie folgt aus:

1: template <typename T>
2: concept CheckOp = requires (T op) { op < op;};
3:
4: template <typename T> requires CheckOp<T>
5: void Sort(T* arr , int size)
6: {...}

Nach dem Schlüsselwort requires folgt eine Parameterklammer mit den formalen Operanden der Operation. Die zu prüfenden Operationen werden anschließend in einer geschweiften Klammer aufgelistet. In unserem Beispiel ist dies nur der Operand <. Die zu prüfende Operation wird nur daraufhin untersucht, ob sie definiert ist, sie wird nicht ausgewertet.

Erfordert ein Funktionstemplate z.B. auch den Operator ++op, kann das Concept CheckOp um diese Bedingung erweitert werden.

1: template <typename T>
2: concept CheckOp = requires (T op) { op < op; ++op; };

Und noch ein letztes Beispiel. Das nachfolgende Concept stellt sicher, dass die Addition sowohl für T1+T2 als auch für T2+T1 definiert ist.

1: template <typename T1, typename T2>
2: concept CheckAdd = requires (T1 op1, T2 op2)
3:                            { op1+op2; op2+op1;};

Anforderung an Schnittstelle

Ebenfalls mithilfe von Concepts können Anforderungen an die Schnittstelle eines Datentyps gestellt werden.

template <typename T>
concept CheckInterface = requires (T obj){ obj.MEMFUNC(); };

Nehmen wir an, ein Funktionstemplate PrintAll() benötigt eine Methode GetStringValue() für die Ausgabe der Daten eines Objekts als String. Das Concept hierfür wäre wie folgt zu defineren:

1: template <typename T>
2: concept CheckInterface =
3:           requires (T obj) { obj.GetStringValue(); };

D.h., innerhalb der geschweiften Klammer des Concepts werden die erforderlichen Methoden aufgelistet. Die Definition des Funktionstemplates erfolgt wie üblich, indem das Concept mittels der requires-Anweisung mit dem Funktionstemplate verlinkt wird.

1: template <typename T> requires CheckInterface<T>
2: void PrintAll(const T* obj, int size)
3: {...}

Anforderung an Returntyp

Die letzte Möglichkeit die wir uns ansehen wollen, ist die Vorgabe des Returntyps eines Funktionstemplates bzw. einer Methode eines Klassentemplates.

template <typename T>
concept CheckRTyp = requires (T obj) {
                       { obj.MEMFUNC() } -> CONDITION;
                    };

Beachten Sie, dass die Methode nochmals in geschweiften Klammern eingeschlossen ist und dass nach der Methode kein Semikolon steht.

Unser Funktionstemplate PrintAll() aus dem vorherigen Abschnitt definiert zwar die Anforderung, dass für die Ausgabe der Daten das übergebene Objekt die Methode GetStringValue() zur Verfügung stellen muss, jedoch kann die Methode noch einen beliebigen Returntyp besitzen. Damit GetStringValue() auch einen std::string zurückliefern muss, wird folgendes Concept definiert:

1: template <typename T>
2: concept CheckRTyp = requires (T obj) {
3:                     { obj.GetStringValue() } -> std::same_as<T,std::string>;
4:                 };
5:
6: template <typename T> requires CheckRTyp<T>
7: void PrintAll(const T* obj, int size)
8: {...}

Sonstiges zu Concepts

Geschachtelte Concepts

Ein geschachteltes Concept ist ein Concept, das in einem anderen Concept enthalten ist.

1: // Operation
2: template <typename T>
3: concept CheckOp = requires (T op) { op < op;};
4: concept Nested = requires (T obj)
5: {
6:    { obj.GetStringValue() } -> std::same_as<T,std::string>;
7:    requires CheckOp<T>;
8: };

Das Concept Nested definiert die Anforderungen, dass die Methode GetStringValue() einen string zurückliefern muss und dass das Concept CheckOp<> erfüllt sein muss.

Alternative Concepts

Ein Concept kann auf vier verschiedene Methoden mit einem Funktionstemplate verbunden werden. Zwei der Methoden sind schon bekannt.

1: template <typename T> requires CheckDTyp<T>
2: bool IsEqual(const T& val1, const T& val2)
3: {...}

und

1: template <typename T>
2: bool IsEqual(const T& val1, const T& val2)
3: requires CheckDTyp<T>
4: {...}

Bei der dritten Methode wird das Concept anstelle des Schlüsselworts typename in der template-Anweisung angegeben:

1: template <CheckDTyp T>
2: bool IsEqual(const T& val1, const T& val2)
3: {...}

Und bei der vierten Methode wird das Concept als Qualifizierer bei den Funktionstemplate-Parametern angegeben. Bei dieser Methode entfällt sogar die template-Anweisung und der 'Datentyp' der Parameter muss in diesem Fall auto sein:

1: bool IsEqual(CheckDTyp auto& val1, CheckDTyp auto& val2)
2: {...}

Copyright 2024 © Wolfgang Schröder
E-Mail mit Fragen oder Kommentaren zu dieser Website an: info@cpp-tutor.de
Impressum & Datenschutz