C++20-Konzepte: Robusterer generischer Code mit Konzepten

Seite 2: Requires Clause

Inhaltsverzeichnis

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.

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.

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.

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.

Aufbau und Inhalt einer Requires Expression (Abb. 3)

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.

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.