Die letzten zwei Artikel dieser Serie stellten die verschiedenen Anwendungsbereiche von Concepts vor. Auf die Pflicht folgt in diesem Artikel die Kür: Die Definition eigener Concepts.
Concepts lassen sich prinzipiell auf zwei Arten definieren: zum einen durch eine Kombination von anderen Concepts und Compile-Zeit-Prädikaten, zum anderen durch Requires-Expressions. Die goldene Regel, das Rad nicht neu zu erfinden, lässt sich auch auf Concepts anwenden. Das bringen auch die C++ Core Guidelines in T.11 [1] deutlich auf den Punkt: “Whenever possible use standard concepts.” Daraus ergibt sich unmittelbar die Frage: Welche vordefinierten Concepts umfasst C++20 eigentlich?
Vordefinierte Concepts
C++20 bringt einen reichen Satz von mehr als 100 Concepts mit. Die folgenden Zeilen sollen nur als kleiner Appetithappen dienen. Genaueres lässt sich im aktuellen Entwurf zum C++23-Standard [2] nachlesen. Die aufgeführten Concepts befassen sich mit elementaren Eigenschaften von Datentypen, arithmetischen Typen, Instanzen eines Datentyps und Objekten (siehe Tabelle “Concepts: Eigenschaften von Datentypen”). Wie lassen sich solche Concepts mithilfe einer Kombination anderer Concepts und Compile-Zeit-Prädikaten oder Requires-Expressions definieren?
|
Concept |
Bedeutung |
|---|---|
|
Datentypen |
|
|
»std::same_as« |
Datentyp identisch |
|
»std::derived_from« |
abgeleiteter Datentyp |
|
»std::convertible_to« |
Datentypen konvertierbar |
|
Arithmetische Typen |
|
|
»std::integral« |
Ganzzahl |
|
»std::signed_integral« |
Ganzzahl mit Vorzeichen |
|
»std::unsigned_integral« |
Ganzzahl ohne Vorzeichen |
|
»std::floating_point« |
Gleitkommazahl |
|
Instanzen |
|
|
»std::constructible_from« |
aus anderer Instanz konstruierbar |
|
»std::default_constructible« |
regulär konstruierbar |
|
»std::move_constructible« |
durch Verschieben konstruierbar |
|
»std::copy_constructible« |
durch Kopieren konstruierbar |
|
»std::destructible« |
zerstörbar |
|
Objekte |
|
|
»std::movable« |
Objekt verschiebbar |
|
»std::copyable« |
Objekt kopierbar |
|
»std::semregular« |
Objekt semiregulär |
|
»std::regular« |
Objekt regulär |
Concepts festlegen
Die Syntax zur Festlegung eines Concepts ist denkbar einfach. Die Definition beginnt wie in der ersten Zeile von Listing 1 mit dem Schlüsselwort »template«, gefolgt von einer Liste von Template-Parametern. Die zweite Zeile im Listing ist interessanter: Sie verwendet das Schlüsselwort »concept«, gefolgt von dem Namen des Concepts und einer Constraint-Expression, also einem Compile-Zeit-Prädikat.
Listing 1
Concept-Definition
template Template-Parameter-Liste concept Concept-Name = Constraint-Expression;
Bei einem Compile-Zeit-Prädikat handelt es sich um einen Ausdruck, der noch während des Übersetzens »true« oder »false« zurückgibt. Er kann eine logische Kombination von anderen Concepts oder Compile-Zeit-Prädikaten sein, die man mit Konjunktionen (»&&«), Disjunktionen (»||«) oder Negationen (»!«) bildet.
Die Constraint-Expression darf alternativ auch eine Requires-Expression sein. Diese lässt sich wiederum auf verschiedene Weise definieren. So ist es möglich, sowohl einfache Typanforderungen als auch zusammengesetzte oder verschachtelte Anforderungen in Requires-Expressions zu verwenden.
Doch damit genug der Theorie, gehen wir zur Praxis über. Im Folgenden definieren wir beide Arten von Concepts. Wie sind nun die C++20-Concepts »std::integral«, »std::signed_integral« und »std::unsigned_integral« implementiert?
Kombinationen
Concepts und Compile-Zeit-Prädikate lassen sich mit Konjunktionen (»&&«) und Disjunktionen (»||«) kombinieren. Darüber hinaus kann man sie negieren (»!«). In bekannter Manier folgt die Evaluation dieser logischen Kombinationen der Kurzschlussauswertung. Das wiederum bedeutet, dass die Auswertung eines logischen Ausdrucks automatisch abbricht, wenn sein Gesamtergebnis bereits feststeht.
Dank der vielen Compile-Zeit-Prädikate der Type-Traits-Bibliothek stehen alle Werkzeuge zur Verfügung, um mächtige Concepts zu implementieren. Listing 2 definiert und verwendet die Concepts »Integral«, »SignedIntegral« und »UnsignedIntegral«.
Listing 2
Integral-Concepts
#include <iostream>
template <typename T>
concept Integral = std::is_integral<T>::value;
template <typename T>
concept SignedIntegral = Integral<T> && std::is_signed<T>::value;
template <typename T>
concept UnsignedIntegral = Integral<T> && !SignedIntegral<T>;
void func(SignedIntegral auto integ) {
std::cout << "SignedIntegral: " << integ << '\n';
}
void func(UnsignedIntegral auto integ) {
std::cout << "UnsignedIntegral: " << integ << '\n';
}
int main() {
std::cout << '\n';
func(-5);
func(5u);
std::cout << '\n';
}
Das Concept »Integral« (Zeile 4) lässt sich direkt mit der Type-Traits-Funktion »std::is_integral« implementieren. Der Ausdruck »std::is_integral<T>::value« gibt zur Compile-Zeit zurück, ob das Template-Argument »T« integral ist. Dank »std::is_signed«, einer weiteren Type-Traits-Funktion, lässt sich das Concept »Integral« direkt zum Concept »SignedIntegral« (Zeile 7) erweitern. Aus der Negation des Concepts »SignedIntegral« resultiert das Concept »UnsignedIntegral« (Zeile 10).
Die »func«-Definitionen (Zeile 12 und 16) wenden die Concepts in der sogenannten Abbreviated-Function-Templates-Syntax an. Dabei handelt es sich um eine vereinfachte Form, Templates zu definieren, die Einschränkungen auf ihren Template-Parametern besitzen. Unser letzter Artikel zu Concepts [3] ging genauer auf diese syntaktische Variante ein.
Die Ausgabe des Programms in Abbildung 1 zeigt schön, dass der Aufruf »func(-5)« (Zeile 22) die Überladung auf »SignedIntegral« und der Aufruf »func(5u)« (Zeile 23) die Überladung auf »UnsignedIntegral« verwendet.
Requires-Expressions
Ein konkreter Datentyp, der sich im C++-Ökosystem intuitiv benutzen lässt, sollte sich wie ein »int« verhalten. Formal betrachtet, sollte der konkrete Datentyp zumindest ein semiregulärer Typ sein. Die C++ Core Guidelines bringen dies in T.46 [4] auf den Punkt: “Require template arguments to be at least semiregular”. Daher bietet der C++20-Standard bereits die Concepts »std::semiregular« und »std::regular« an. Diese Concepts lassen sich zu Übungszwecken auch selbst definieren und mit den C++20-Pendants vergleichen.
Jetzt bleibt nur noch eine wichtige Frage zu beantworten: Welche Eigenschaften müssen reguläre beziehungsweise semireguläre Datentypen besitzen? Ein semiregulärer Datentyp »X« muss die Großen Sechs (siehe Tabelle “Die Großen Sechs”) sowie »swap(X&,X&)« unterstützen. Zudem muss »X« ebenfalls »swap(X&,X&)« unterstützen.
|
Funktion |
Signatur |
|---|---|
|
Standardkonstruktor |
»X()« |
|
Copy-Konstruktor |
»X(const X&)« |
|
Copy-Zuweisungsoperator |
»X& operator = (const X&)« |
|
Move-Konstruktor |
»X(X&&)« |
|
Move-Konstruktor |
»X& operator = (X&&)« |
|
Destruktor |
»~X()« |
Die Type-Traits-Bibliothek [5] macht die Definition des entsprechenden Concepts zum Kinderspiel. Das Type-Trait »isSemiRegular« lässt sich direkt implementieren und dann zur Definition des Concepts in Listing 3 verwenden.
Listing 3
SemiRegular-Concept
template<typename T>
struct isSemiRegular: std::integral_constant<bool,
std::is_default_constructible<T>::value &&
std::is_copy_constructible<T>::value &&
std::is_copy_assignable<T>::value &&
std::is_move_constructible<T>::value &&
std::is_move_assignable<T>::value &&
std::is_destructible<T>::value &&
std::is_swappable<T>::value >{};
template<typename T>
concept SemiRegular = isSemiRegular<T>::value;
Ein Datentyp unterstützt die Typeigenschaft »isSemiRegular« (Zeile 2), wenn alle Typeigenschaften der Großen Sechs (Zeilen 3 bis 8) und die Bedingung »std::is_swappable« (Zeile 9) erfüllt sind. Der verbleibende Schritt zur Definition des Concepts »SemiRegular« ist die Verwendung des Typ-Traits »isSemiRegular« (Zeile 12).
Nun fehlt nur noch eine Kleinigkeit, um das Concept »Regular« zu definieren. Ein Datentyp »T« unterstützt das Concepts »Regular«, wenn er semiregulär ist und sich auf Gleichheit überprüfen lässt. Listing 4 definiert dazu das Concept »Equal« und verwendet es direkt zur Definition des Concepts »Regular«.
Listing 4
Equal und Regular
template<typename T>
concept Equal = requires(T a, T b) {
{ a == b } -> std::convertible_to<bool>;
{ a != b } -> std::convertible_to<bool>;
};
template<typename T>
concept Regular = Equal<T> && SemiRegular<T>;
Bei der Definition des Concepts »Equal« kommt eine zusammengesetzte Requires-Expression zum Einsatz. »Equal« fordert von zwei Werten »a« und »b« des identischen Typs »T«, dass sie sich auf Gleichheit »==« und Ungleichheit »!=« vergleichen lassen. Zudem müssen beide Operationen einen Wert zurückgeben, den man zu einem Wahrheitswert konvertieren kann. Ich denke, Sie ahnen es bereits: » std::convertible_to<bool>« ist ein Concept. Requires-Expressions lassen sich in vielen anderen Formen anwenden [6].
Haben Sie erst einmal das Concept »Equal« implementiert, ist die Definition des Concepts »Regular« nur noch eine Pflichtaufgabe, bei der Sie das Concept »Equal« mit dem Concept »SemiRegular« konjugieren. Der C++20-Standard setzt die Concepts »std::semiregular« und »std::regular« aus elementareren Concepts zusammen (Listing 5).
Listing 5
std::equal und std::semiregular
template<class T>
concept movable = is_object_v<T> &&
move_constructible<T> &&
assignable_from<T&, T> &&
swappable<T>;
template<class T>
concept copyable = copy_constructible<T> &&
movable<T> &&
assignable_from<T&, T&> &&
assignable_from<T&, const T&> &&
assignable_from<T&, const T>;
template<class T>
concept semiregular = copyable<T> &&
default_initializable<T>;
template<class T>
concept regular = semiregular<T> &&
equality_comparable<T>;
Interessanterweise ist das Concept »std::regular« in C++20 ähnlich definiert wie das benutzerdefinierte Concept »Regular«. Das C++20-Concept »std::semiregular« hingegen besteht aus einer Kombination grundlegenderer Concepts aus »std::copyable« und »std::moveable«. Daraus folgt, dass ein kopierbarer Datentyp automatisch auch die Move-Semantik unterstützt.
Das Concept »std::movable« basiert auf der Type-Traits-Funktion »std::is_object« [7]. Bei den verbleibenden Komponenten der Concepts »std::moveable« und »std::copyable« handelt es sich um weitere Concepts des C++20-Standards [8].
Was passiert nun, wenn man das Concept »std::regular« anwendet? Das Funktions-Template »behavesLikeAnInt« in Listing 6 zeigt das Concept im Einsatz.
Listing 6
std::equal und std::unequal
#include <concepts>
#include <vector>
template <std::regular T>
void behavesLikeAnInt(T) {
[...]
}
struct NotEqualityComparable { };
int main() {
int myInt{};
behavesLikeAnInt(myInt);
std::vector<int> myVec{};
behavesLikeAnInt(myVec);
NotEqualityComparable notEquComp;
behavesLikeAnInt(notEquComp);
}
Um das Concept »std::regular« zu benutzen, muss der Header »<concepts« verwendet werden. Die Funktion »behavesLikeAnInt« in Zeile 5 fordert, dass »T« das Concept »std::regular« umsetzt. Dieses Concept unterstützt »int« (Zeile 12) und »std::vector« (Zeile 14). Der minimale Datentyp »NotEqualityComparable« lässt sich hingegen nicht auf Gleichheit vergleichen – genau das moniert der Compiler. Abbildung 2 zeigt die entscheidenden Zeilen der GCC-Fehlermeldung.
Fazit und Ausblick
Concepts sind eine Revolution in modernem C++. Dank ihnen lassen sich Templates definieren, die semantische Kategorien wie Ordnung, Regular oder Zahl voraussetzen. Sie sind ähnlich fundamental für Templates wie Interfaces für das objektorientierte Design.
Die Ranges-Bibliothek stellt eine zweite Variante der Standard Template Library dar. Sie basiert auf Concepts und öffnet C++20 für ganz neue Ideen aus der funktionalen Welt. So lassen sich Algorithmen der Ranges-Bibliothek mit dem aus der Unix-Welt bekannten Pipe-Operator »|« verknüpfen und auf unendlichen Datenströmen ausführen. Ranges verändern C++ ähnlich fundamental wie Concepts. (jcb/jlu)
Infos
- T.11: https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Rt-std-concepts
- C++23-Standard-Entwurf: https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2023/n4928.pdf
- C++: Rainer Grimm, “Vielseitiges Werkzeug”, LM 06/2023, S. 68, https://www.lm-online.de/48941
- T.46: https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Rt-regular
- Type-Traits-Bibliothek: https://en.cppreference.com/w/cpp/header/type_traits
- Requires-Expression: https://en.cppreference.com/w/cpp/language/requires
- »std::std::is_object«: https://en.cppreference.com/w/cpp/types/is_object
- Concepts-Bibliothek: https://en.cppreference.com/w/cpp/concepts








