Concepts

Ein Concept definiert eine Bedingung für die Instanziierung eines Funktions-, Klassen- oder Lambda-Templates (im Folgenden vereinfacht nur Template genannt). 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. Und nur wenn das Concept true zurückliefert erfolgt die Instanziierung des Templates.

requires-Anweisung

Um eine Bedingung für die Instanziierung eines Klassentemplates zu definieren, folgt nach der spitzen Klammer der template-Anweisung das Schlüsselwort requires und anschließend die Bedingung.

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

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

Bei Funktions- oder Lambda-Templates kann die Bedingung 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 die Instanziierung von mehreren Bedingungen abhängig, können diese mit den Operatoren && oder || verknüpft werden.

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

Im einfachsten Fall wird für CONCEPT 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 den gleichen 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 vergleicht zwei Daten auf Gleichheit
template <typename T>
bool IsEqual(const T& val1, const T& val2)
{
    return val1 == val2;
}
int main()
{
    // Zu vergleichender Wert
    float fval1 = 10.3f;
    // Werte zwischen 10.0f und 10.6f vergleichen
    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 Gleitkommadaten kommt es hier zu Rundungsfehlern bei der Addition mit der Schleifenvariable fval2. Dies ist auch der Grund, warum Gleitkommadaten nie ohne besondere Vorkehrung auf Gleichheit abgeprüft werden sollten.

Nun sollen zwei Gleitkommadaten 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 Gleitkommadaten sind. Das entsprechende Concept dazu ist in der Standardbibliothek unter dem Namen floating_point<T> definiert.

#include <print>
#include <concepts>
#include <cmath>

// Template vergleicht zwei Daten auf Gleichheit
template <typename T>
bool IsEqual(const T& val1, const T& val2)
{
    return val1 == val2;
}
// Template vergleicht zwei Geleitkommadaten
// Die Daten sind identisch, wenn deren
// Differenz kleiner 0.0001f ist
template <typename T>
bool IsEqual(const T& val1, const T& val2)
requires std::floating_point<T>
{
    return fabs(val1 - val2) < 0.0001f;
}

int main()
{
    // Zu vergleichender Wert
    float fval1 = 10.3f;
    // Werte zwischen 10.0f und 10.6f vergleichen
    for (float fval2 = 10.0f; fval2 < 10.6f; fval2 += 0.1f)
        std::println("{} == {}: {}", fval1, fval2,
                     IsEqual(fval1, fval2));
}

Da nun für Gleitkommadaten 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, eines für jeden Gleitkomma-Datentyp.

concept-Anweisung

Zur Definition von eigenen Concepts dient die concept-Anweisung:

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.

Um ein auf diese Weise definiertes Concept als Bedingung für die Instanziierung eines Klassentemplates einzusetzen, wird dessen Name nach der requires-Anweisung angegeben, gefolgt vom formalen Parameter in spitzen Klammern.

template <typename T> requires NAME<T>
class CAny
{
   ...
}

Soll das Concept auf eine Funktionstemplate oder eine Methode angewandt werden, so kann die requires-Anweisung wiederum alternativ nach der Parameterklammer stehen.

template <typename T>
void DoAnything(T param) requires NAME<T>
{
...
}

Sehen wir uns nun anhand von Beispielen an, welche Möglichkeiten die Definition von eigenen Concepts eröffnet.

Folgende beide Ausgangsklassen sind definiert:

#include <print>
#include <concepts>
#include <algorithm>
#include <string>

template <typename T, int SIZE>
class Data
{
    T data[SIZE];       // Datenfeld
public:
    // ctor, belegt Datenfeld mit Zufallszahlen
    Data()
    {
        for (auto index = 0; index < SIZE; index++)
            data[index] = std::rand() % 20;
    }
    // Integer- oder Gleitkommadaten ausgeben
    void PrintData()
    {
        for (int index=0; index<SIZE; index++)
                std::print("{}, ", data[index]);
        std::println("");
    }
};

// Beliebige weitere Klasse
class CAny
{
    int value;
public:
    CAny()
    {
        value = std::rand() % 20;
    }
    auto GetData() const
    {
        return value;
    }
};

int main()
{
    // float-Wert abspeichern
    Data<float,5> iobj;
    iobj.PrintData();
}

1, 7, 14, 0, 9,

Die Klasse Data dient zur Ablage und Ausgabe von Daten eines beliebigen Datentyps in einem Feld, dessen Größe bei der Definition des Objekts festgelegt wird.

Die Klasse CAny ist eine beliebige Klasse und dient im Beispiel dazu, aufzuzeigen wie auch Objekte anderer Klassen in einem Data-Objekt abgelegt werden können.

Anforderung an Datentyp

Um eine Methode oder eine Template für bestimmte Datentypen zu instanziieren, können z.B. die Concepts std::same_as<T1,T2> oder std::integral<T> aus der Standardbibliothek eingesetzt werden.

template <typename T>
concept NAME = CONCEPTx;

NAME ist der Name des Concepts und CONCEPTx eines oder mehrere der in der Standardbibliothek vordefinierten Concepts.

Im obigen Beispiel kann der Konstruktor der Klasse Data nur intrinsische Datentypen verarbeiten, da das Ergebnis von std::rand() nicht in andere Datentyp konvertiert weden kann. Aus diesem Grund ist für alle anderen Datentypen ein weiterer Konstruktor erforderlich.

Eine Lösung hierfür kann wie folgt aussehen:

// Concept fuer Integer- und Gleitkomma-Daten
template <typename T>
concept is_numeric = std::integral<T> || std::floating_point<T>;

template <typename T, int SIZE>
class Data
{
    ...
    // ctor fuer numerische Daten
    Data()
    requires is_numeric<T>
    {
        for (auto index = 0; index < SIZE; index++)
            data[index] = std::rand() % 20;
    }
    // ctor fuer alle anderen Datentypen
    Data()
    {
        for (auto index = 0; index < SIZE; index++)
            data[index] = T{};	// Aufruf Standard-ctor
    }
}

Das vollständige Listing zu diesem und allen nachfolgenden Beispielen finden Sie am Ende dieses Unterkapitels.

Anforderung an Schnittstelle

Bleibt noch das Problem der Ausgabe der im Feld abgelegten CAny-Objekte.

class Data
{
   ...
   // Integer- oder Gleitkommadaten ausgeben
   void PrintData()
   {
      for (int index=0; index<SIZE; index++)
          std::print("{}, ", data[index]);
      std::println("");
   }
};

Auch hier wird der Compiler wieder einen Fehler melden, da print() standardmäßig keine Objekte ausgeben kann. Zum einen könnte man, wie im Kapitel Template-Spezialitäten aufgeführt, das Template formatter für den Datentyp CAny spezialisieren. Oder aber man spezialisiert wiederum die Methode PrintData() für alle nicht numerischen Daten.

// Concept fuer Integer- und Gleitkomma-Daten
template <typename T>
concept is_numeric = std::integral<T> || std::floating_point<T>;

template <typename T, int SIZE>
class Data
{
    T data[SIZE];       // Datenfeld
public:
    ...
    // Integer- oder Gleitkommadaten ausgeben
    void PrintData()
    requires is_numeric<T>
    {
        for (int index=0; index<SIZE; index++)
                std::print("{}, ", data[index]);
        std::println("");
    }
    // Alle anderen Daten ausgeben
    void PrintData()
    {
        for (int index=0; index<SIZE; index++)
                std::print("{}, ", data[index].GetData());
        std::println("");
    }
};

Damit können Objekte der Klasse CAny ausgegeben werden, Objekte anderer Klassen aber wahrscheinlich nicht, da deren Klasse ebenfalls die Methode GetData() besitzen müsste. Die Prüfung, ob ein Datentyp eine bestimmte Methode zur Verfügung stellt,  erreicht man durch ein Concept das die Schnittstelle einer Klasse prüft.

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

MEMFUNC ist der Name der Methode die eine Klasse zur Verfügung stellen muss. Für unser Beispiel sieht dies wie folgt aus:

// Concept fuer Integer- und Gleitkomma-Daten
template <typename T>
concept is_numeric = std::integral<T> || std::floating_point<T>;

// Concept fuer eine Schnittstellenpruefung (hier GetData())
template <typename T>
concept checkInterface = requires (T obj)
                        { obj.GetData(); };

template <typename T, int SIZE>
class Data
{
    T data[SIZE];       // Datenfeld
public:
    ...
    // Integer- oder Gleitkommadaten ausgeben
    void PrintData()
    requires is_numeric<T>
    { ... }
    // Alle anderen Daten ausgeben
    void PrintData()
    requires checkInterface<T>
    {
        for (int index=0; index<SIZE; index++)
                std::print("{}, ", data[index].GetData());
        std::println("");
    }
};

Anforderung an Operatoren

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

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

OPERAND ist der Operator, der für den Datentyp T definiert sein muss.

Nehmen wir an, die Klasse Data wird um eine Methode Sort() erweitert um die abgelegten Daten auch sortieren zu können. Der Vergleich der Daten soll mit dem Operator > erfolgen. D.h. Sort() stellt die Anforderung an die zu sortierenden Daten, dass die Vergleichsoperation > definiert ist.

Für numerische Daten ist dies automatisch der Fall. Alle anderen Datentypen müssen diesen Operanden explizit definieren.

// Concept fuer die Pruefung auf einen Operanden
// (hier Operand < )
template <typename T>
concept greater = requires (T op) { op > op; };

template <typename T, int SIZE>
requires greater<T>
class Data
{
    ...
    void Sort()
    {
	    ...
        if (data[index] > data[index + 1])
        {...}
    }
};

// Beliebige weitere Klasse
class CAny
{
    int value;
    ...
    bool operator > (this const CAny& self, const CAny& op2)
    {
        return self.value > op2.value;
    }
};

Beachten Sie, dass das Concept nun auf das Klassentemplate angewandt wird und nicht mehr auf eine Methode, da der Datentyp den Operanden zur Verfügung stellen muss.

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 NAME = 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.

Nehmen wir für das Beispiel an, dass alle GetData() Methoden für die Ausgabe von nicht-numerischen Daten ihre formatieren Daten in einem std:string zurückliefern sollen.

// Prueft Datentyp des Rueckgabewertes
// (Bsp: GetData() muss std::string zurueckgeben)
template <typename T>
concept asString = requires (T obj)
            { {obj.GetData()} -> std::same_as<std::string>;};

template <typename T, int SIZE>
class Data
{
    ...
    // Alle anderen Daten ausgeben
    void PrintData()
    requires asString<T>
    {
        for (int index=0; index<SIZE; index++)
                std::print("{}, ", data[index].GetData());
        std::println("");
    }
};

// Beliebige weitere Klasse
class CAny
{
    int value;
    ...
    auto GetData() const
    {
        return std::format("Wert: {}",value);
    }
};

Das vollständige Beispiel

Hier nun der vollständige Code zu allen concept-Beispielen:

#include <print>
#include <concepts>
#include <string>

// Concept fuer Pruefung auf Datentyp
template <typename T>
concept is_numeric = std::integral<T> || std::floating_point<T>;

// Concept fuer eine Schnittstellenpruefung
// (Bsp: Datentyp muss GetData() Methode defineren)
template <typename T>
concept checkInterface = requires (T obj)
{
    obj.GetData();
};

// Concept fuer die Pruefung auf Operanden
// (Bsp: Datentyp muss Operand < defineren)
template <typename T>
concept greater = requires (T op) { op > op; };

// Concept fuer Pruefung des Datentyps eines Rueckgabewertes
// (Bsp: GetData() muss std::string zurueckgeben)
template <typename T>
concept asString = requires (T obj)
{ { obj.GetData() } -> std::same_as<std::string>;
};

// Alle abzulegenden Daten muessen mit dem
// Operator > verglichen werden koennen
template <typename T, int SIZE>
    requires greater<T>
class Data
{
    T data[SIZE];       // Datenfeld
public:
    // ctor fuer numerische Daten
    Data()
        requires is_numeric<T>
    {
        for (auto index = 0; index < SIZE; index++)
            data[index] = std::rand() % 20;
    }
    // ctor fuer alle anderen Datentypen
    Data()
    {
        for (auto index = 0; index < SIZE; index++)
            data[index] = T{};
    }
    void Sort()
    {
        bool changed;
        do
        {
            changed = false;
            for (auto index = 0; index < SIZE - 1; index++)
            {
                if (data[index] > data[index + 1])
                {
                    T temp = std::move(data[index]);
                    data[index] = std::move(data[index + 1]);
                    data[index + 1] = std::move(temp);
                    changed = true;
                }
            }
        } while (changed);
    }
    // Integer- oder Gleitkommadaten ausgeben
    void PrintData()
        requires is_numeric<T>
    {
        for (int index = 0; index < SIZE; index++)
            std::print("{}, ", data[index]);
        std::println("");
    }
    // Alle anderen Daten ausgeben
    // Datentyp muss GetData() definiert haben und
    // GetData() muss einen std::string zurueckgeben
    void PrintData()
        requires checkInterface<T>&& asString<T>
    {
        for (int index = 0; index < SIZE; index++)
            std::print("{}, ", data[index].GetData());
        std::println("");
    }
};

// Beliebige weitere Klasse
class CAny
{
    int value;  // beliebiges Datum
public:
    // ctor, initialisiert Datum mit Zufallswert
    CAny()
    {
        value = std::rand() % 20;
    }
    // Liefert formatiertes Datum als std::string
    auto GetData() const
    {
        return std::format("Wert: {}", value);
    }
    // Vergleicht zwei CAny-Objekte
    bool operator > (this const CAny& self, const CAny& op2)
    {
        return self.value > op2.value;
    }
};

int main()
{
    // float-Werte ablegen
    Data<float, 5> fobj;
    std::print("Data<float,5>: ");
    fobj.PrintData();
    // Daten sortieren und ausgeben
    fobj.Sort();
    std::print("Data<float,5> sortiert: ");
    fobj.PrintData();

    // CAny-Objekte ablegen
    Data<CAny, 4> cobj;
    std::print("Data<CAny,4>: ");
    cobj.PrintData();
    // Daten sortieren und ausgeben
    cobj.Sort();
    std::print("Data<CAny,4> sortiert: ");
    cobj.PrintData();
}

Data<float,5>: 1, 7, 14, 0, 9,
Data<float,5> sortiert: 0, 1, 7, 9, 14,
Data<CAny,4>: Wert: 4, Wert: 5, Wert: 5, Wert: 1,
Data<CAny,4> sortiert: Wert: 1, Wert: 4, Wert: 5, Wert: 5,

Sonstiges zu Concepts

Geschachtelte Concepts

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

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

concept Nested = requires (T obj)
{
   { obj.GetStringValue() } -> std::same_as<T,std::string>;
   requires CheckOp<T>;
};

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

Alternative Concept-Bindungen

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

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

und

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

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

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

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:

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

Übungen

concept_01:

Vorgegeben ist folgende Klasse Complex sowie die main() Funktion:

#include <print>
#include <iostream>
#include <concepts>

// Klassentemplate fuer komplexe Zahlen
// Der Real- und Imaginaereanteil muss ein
// Gleitkomma-Datentyp sein
template <std::floating_point T>
class Complex
{
    T real;     // Realanteil
    T imag;     // Imaginaeranteil
public:
    // ctor, initialisiert Daten
    Complex(T _real, T _imag) : real(_real), imag(_imag)
    {}
    // Ueberladener Operator << fuer die Datenausgabe
    friend std::ostream& operator << (std::ostream& os,
                                      const Complex& obj)
    {
        os << std::format("{}+{}i", obj.real, obj.imag);
        return os;
    }
};

int main()
{
    // Summe zweier int-Variablen ausgeben
    int ival1 = 10, ival2 = 5;
    std::println("Summe von {} und {} ist: {}",
                 ival1, ival2, Sum(ival1,ival2));
    // Summe zweier komplexer Zahlen ausgeben
    //Complex comp1{ 1.2,3.4 }, comp2{ 4.2,6.4 };
    //std::cout << "Summe von " << comp1 << " und " << comp2
    //    << " ist: " << Sum(comp1, comp2) << '\n';
}

Implementieren Sie ein Funktionstemplate Sum() zur Addition von zwei Daten des gleichen Datentyps. Als Bedingung (Concept) für Sum() gilt, dass die Daten die Operationen a+b und a+= b zur Verfügung stellen müssen.

Testen Sie die Implementierung, indem Sie das Programm starten.

Entfernen Sie die Kommentare in den Zeilen 33...35 und erweitern Sie die Klasse Complex, sodass auch die Summe zweier komplexer Zahlen berechnet werden kann.

Summe von 10 und 5 ist: 15
Summe von 1.2+3.4i und 4.2+6.4i ist: 5.4+9.8i