C++20-Konzepte: Robusterer generischer Code mit Konzepten
Seite 2: Requires Clause
Bei der Form C2
taucht das erste Mal das neue Schlüsselwort requires
auf. Es leitet direkt nach dem Template-Kopf eine Einschränkung oder auch eine Reihe von Einschränkungen ein. Dieser Abschnitt ist in einigen Fällen notwendig, da etwa C1
nicht alle Anwendungsfälle abdeckt. Beispielsweise kann der Vergleich von mehreren Template-Parametern nicht in C1
erreicht werden.
Constrained Placeholder Type
Mit C3
und C4
kommt die Möglichkeit hinzu, auto
-Variablen und -Parameter einzuschränken. Der Einsatz von C3
ist nicht nur, wie in Abbildung 2 zu sehen, auf den Rückgabedatentyp beschränkt. Gleiches lässt sich auch für auto
-Variablen nutzen.
C4
funktioniert analog zu C3
nur für Parameter. Hier ist eine weitere Neuerung von C++20 zu sehen: Parameter mit auto
-Datentyp. Sie sind bereits seit C++14 in generischen Lambdas in C++ enthalten, doch erst C++20 erlaubt eine breitere Anwendung außerhalb von generischen Lambdas.
Trailing Requires Clause
Am Ende steht die Trailing Requires Clause C5
, wie C2
durch das Schlüsselwort requires
eingeleitet. In einem Klassen-Template lassen sich damit Methoden beschränken, die selbst kein Template sind. Gute Kandidaten sind der Kopierkonstruktor oder -destruktor. Vor C++20 erforderte das weitgehend eine komplizierte Ableitung.
Requires Expression
Bisher diente requires
zum Einleiten einer Requires Clause oder einer Trailing Requires Clause. Eine weitere Anwendung ist der Requires-Ausdruck (Requires Expression). Wie Abbildung 3 zeigt, ähnelt das einer Funktion. Eingeleitet durch requires
folgt die Parameterliste. Die Datentypen dieser Parameter können Parameter aus einem Template-Kopf sein oder Datentypen, die bekannt sind. Da ein Requires-Ausdruck vom Compiler nur für die Prüfung verwendet wird, ob der Ausdruck valide ist, lassen sich die Parameter recht frei bestimmen. Es können Objekte sein, Referenzen oder Zeiger, jeweils mit oder ohne const
.
Ein Requires-Ausdruck produziert einen booleschen Wert, der von einer Requires Clause verwendet wird, um zu prüfen, ob die an das Template gestellten Einschränkungen erfüllt sind. Letztere befinden sich im Rumpf des Requires-Ausdrucks.
Vier Arten von Requirements
Der Standard kennt vier verschiedene Requirement-Arten, die in einem Requires-Ausdruck verwendet werden können:
- Simple Requirement (SR)
- Nested Requirement (NR)
- Compound Requirement (CR)
- Type Requirement (TR)
Wie der Name andeutet, handelt es sich bei einem Simple Requirement um eine einfache Anforderung. Geprüft wird, ob ein Ausdruck valide ist, also instanziierbar, damit sie kompiliert. Ein Beispiel ist die Überprüfung, ob ein Datentyp T
über die Methoden begin
und end
verfügt:
template<typename T>
concept iterator = requires(T t)
{
t.begin(); // SR
t.end(); // SR
};
In diesem Code ist der Requires-Ausdruck dem Konzept iterator
zugewiesen, das den Template-Parameter T
erfordert. Neben der Existenz von Methoden können auch der Aufruf einer Funktion mit Parametern oder das Expandieren eines Parameter-Packs getestet werden.
Ein Nested Requirement prüft den zur Compile-Zeit errechneten booleschen Wert eines Ausdrucks, etwa eines Type Trait. Diese Anforderung wird durch requires
eingeleitet. Ohne das führende requires
würde es sich um ein Simple Requirement handeln, und der Wert wäre egal. Es würde einzig zählen, ob der Ausdruck valide wäre. Das ist eine unschöne Stolperfalle bei den Anforderungsarten – bitte aufpassen!
Aber zurück zum Nested Requirement: Eine weitere potenzielle Einschränkung an den Datentyp T
ist, dass die Klasse final
ist. In diesem Fall kann direkt der zugehörige Type Trait is_final_v
verwendet werden. Das folgende Listing zeigt die Variante mit der zusätzlichen final
-Einschränkung:
template<typename T>
concept iterator = requires(T t)
{
t.begin(); // SR
t.end(); // SR
requires std::is_final_v<T>; // NR
};
Als Wert in einem Nested Requirement sind alle Inhalte erlaubt, die einen booleschen Wert zur Compile-Zeit liefern. Hierzu zählen constexpr
-Funktionen oder auch sizeof
, um die Größe eines Datentyps zu ermitteln.
Mit einem Compound Requirement lassen sich zwei verschiedene Einschränkungen testen, wahlweise getrennt oder in einem Statement. Die geschweiften Klammern, die die Einschränkung umschließen, signalisieren ein Compound Requirement. Prüfbar ist:
- ob der Ausdruck im Compound Requirement
noexept
ist - ob der Rückgabedatentyp eine Bedingung erfüllt.
Eine weitere denkbare Prüfung beziehungsweise Einschränkung an den Datentyp T
ist, dass begin
und end
keine Exceptions werfen und deshalb mit noexcept
markiert sind. Außerdem sollen die Rückgabedatentypen von begin
und end
gleich sein. Welcher Datentyp es genau ist, bleibt offen. Im folgenden Listing sind alle drei möglichen Kombinationen zu sehen, um diese Einschränkungen zu prüfen:
template<typename T>
concept iterator = requires(T t)
{
t.begin(); // SR
t.end(); // SR
requires std::is_final_v<T>; // NR
{
t.begin()
}
noexcept; // CR 1
{
t.begin()
} -> std::same_as<decltype(t.end())>; // CR 2
{
t.begin()
}
noexcept->std::same_as<decltype(t.end())>; // CR 3
};
CR 1
zeigt die Variante, die nur prüft, dass begin
noexcept
ist. Um den Code kurz zu halten, wird an dieser Stelle auf end
verzichtet.
CR 2
hingegen stellt sicher, dass begin
und end
denselben Rückgabedatentyp haben. Hier kommt mit same_as
ein bereits bekanntes Konzept zum Einsatz. Dabei kann außerdem nur ein Konzept verwendet werden. Ein Type Trait funktioniert nicht. Der Grund wird bei einer genaueren Analyse klar: same_as
benötigt, wie der Name impliziert, eigentlich zwei Parameter, denn was soll es sonst prüfen? Der Rückgabedatentyp des Ausdrucks t.begin()
ist jedoch nicht benennbar. Damit können Entwicklerinnen und Entwicklern ihn nicht in same_as
als Parameter eingefügen. Hier kommt der Compiler ins Spiel. Er kennt den Datentyp sehr gut und besitzt die Fähigkeit, diesen nur ihm bekannten Datentyp als ersten Parameter in same_as
zu injizieren. Aus diesem Grund funktioniert same_as
mit nur einem Parameter.
CR 3
zeigt die Kombination aus den beiden Varianten CR 1
und CR 2
.
Als vierte Anforderung prüft das Type Requirement, ob ein bestimmter Datentyp in einer Klasse oder einem Namensraum verfügbar ist. Das folgende Listing zeigt zwei Beispiele. Das Konzept TypeCheck
testet mit einem Requires-Ausdruck, ob im Namensraum MySpace
beziehungsweise im Namensraum von T
ein Datentyp Int
existiert.
struct A
{
using Int = int;
};
namespace MySpace {
using Int = int;
}
template<typename T>
concept TypeCheck = requires(T t)
{
typename MySpace::Int;
typename T::Int;
};
Wie im Listing zu sehen ist, muss der Datentyp Int
nicht zwangsläufig selbst wieder eine Klasse oder Ahnliches sein; ein using
-Alias ist ebenfalls möglich.