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

Concepts sind eine Neuerung in C++20. Teil 1 der zweiteiligen Artikelserie stellt ihre Grundelemente vor: Mit ihnen lassen sich Anforderungen definieren.

In Pocket speichern vorlesen Druckansicht 131 Kommentare lesen
C++20-Konzepte: Robusterer generischer Code mit Konzepten

(Bild: RHJPhtotoandilustration/Shutterstock.com)

Lesezeit: 13 Min.
Von
  • Andreas Fertig
Inhaltsverzeichnis

C++-Entwicklerinnen und -Entwickler können mit Konzepten Anforderungen (Requirements) beziehungsweise Einschränkungen (Constraints) für einen Datentyp formulieren. Etwas Vergleichbares gab es bereits vor C++20 in Form von enable_if. Diese Variante ist im Vergleich zu Konzepten allerdings ein einfaches Mittel, das mit der Mächtigkeit von Konzepten nicht mithalten kann, die bereits für C++11 geplant waren, dann aber noch vor der Finalisierung des Standards entfernt wurden.

C++20-Konzepte

Ein Konzept in C++ besteht aus einem Namen, mit dem es angesprochen wird, und einer oder mehreren Einschränkungen. Um ein Konzept formulieren zu können, bietet C++20 zwei neue Schlüsselwörter: concept und requires (s. Abb. 1).

Die einzelnen Elemente einer Konzeptdefinition (Abb. 1)

Da ein Konzept immer für generischen Code existiert, beginnt es mit einem Template-Kopf wie bei Funktions- oder Klassen-Templates. Danach leitet das Schlüsselwort concept das Konzept selbst ein und vergibt einen Namen wie bei einer Variablen. In diesem Fall lautet er MyConcept. Auf der rechten Seite des Gleichheitszeichens stehen die Einschränkungen, die dem Konzept zugewiesen werden. Sie können mittels boolescher Ausdrücke verknüpft werden. Im hier gezeigten Beispiel müssen die beiden Datentypen T und U dem gleichen Datentyp angehören. Das Konzept same_as prüft das. Außerdem muss T entweder ein Klassen- oder ein enum-Datentyp sein. Hier kommen die booleschen Verknüpfungen && oder || zum Einsatz. Die beiden Type Traits is_class_v und is_enum_v, die bereits vor C++20 existierten, prüfen die weiteren Einschränkungen.

Damit steht das Grundgerüst von Konzepten. Die Definition von eigenen Konzepten ist durch die Einleitung mit dem Schlüsselwort concept und durch das Zuweisen von Einschränkungen möglich. Die Standardbibliothek von C++20 bringt bereits über 30 vordefinierte Konzepte mit.

Konzepte beziehungsweise Einschränkungen sind in fünf verschiedenen Bereichen einsetzbar (s. Abb. 2).

Die verschiedenen Orte, an denen Konzepte verwendet werden können (Abb. 2).

Hilfreich ist die in der Abbildung gezeigten Form C1, denn das Konzept C1 ersetzt typename oder class. Allein ein gut gewählter Konzeptname schafft bereits sehr viel mehr Klarheit. Während sich hinter typename ein beliebiger Template Type verbirgt, kann der Name eines Konzepts mitteilen, dass dieses Funktions-Template beispielsweise ein std::invocable erfordert. Der Name gibt also einen Hinweis, dass der Datentyp ausführbar und somit eine Funktion, ein Lambda oder ein Callable sein muss. Wer Wert auf Clean Code legt, findet darin noch einmal einen "Turbo", unabhängig von all dem, was Concepts sonst noch leisten.

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.

Die Abgrenzung der beiden Begriffe ist an dieser Stelle schwierig. Der Standard spricht im Fall eines Konzepts von Einschränkung (Constraint). Eine Anforderung (Requirement) ist es, wenn das neue Schlüsselwort requires zum Einsatz kommt. Grob gesagt ist der Unterschied, dass ein Constraint immer einen Datentyp einschränkt. Ein Requirement hingegen formuliert eine Anforderung an eine Funktion oder ein Klassen-Template oder leitet eine Menge von Einschränkungen ein.

Eine andere Sicht ist, dass die Schreiberin oder der Schreiber einer Funktion sie durch Konzepte einschränkt. Anwender hingegen nehmen diese Einschränkungen eher als Anforderungen wahr.

Bisher ist der requires-Ausdruck immer mit einem Konzept verknüpft worden. Das ermöglicht die Wiederverwendung und ist in der Regel das, was gewünscht ist. Mit der Ad-hoc-Einschränkung (Ad hoc Constraint) gibt es eine weitere Möglichkeit, einen Requires-Ausdruck einzusetzen.

Angenommen, das iterator-Konzept und der dahinter stehende Requires-Ausdruck sollen für eine Funktion Fill verwendet werden. Ihre Aufgabe soll es sein, alle Elemente mit einem benutzerbestimmten Wert zu füllen. Die Implementierung nutzt std::for_each und erfordert somit einen Datentyp T, der begin und end bereitstellt. In C++20 ist zusätzlich std::ranges::for_each verwendbar, was die Lesbarkeit des Codes erhöht.

Dieses Listing zeigt den Einsatz des Requires-Ausdrucks als Ad-hoc-Einschränkung:

template<typename T>
requires 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
}
void Fill(T& t, int value)
{
    std::ranges::for_each(t, [value](auto& e) { e = value; });
}

Eine solche Ad-hoc-Einschränkung ist an requires requires erkennbar. Das erste requires leitet eine Requires Clause ein (im Beispiel initial C2, s. Abb. 2), das zweite requires startet den Requires-Ausdruck.

Wenn es darum geht, eine Einschränkung zu formulieren, die für diese eine Funktion oder Klasse exklusiv ist, hat eine Ad-hoc-Einschränkung sicherlich ihren Charme. Doch meist ist die Wiederverwendbarkeit eines Konzepts das höhere Gut. Immerhin kostet es einiges an Aufwand, die Einschränkungen korrekt zu formulieren, sodass es nicht hilfreich ist, sie nur einmalig verwenden zu können. Das ist einer der Gründe, weshalb Ad-hoc-Einschränkungen immer ein erstes Anzeichen für einen Code Smell darstellen. Allerdings ist eine genauere Prüfung nötig, ob im Einzelfall eine Ad-hoc-Einschränkung nicht doch das Richtige ist. Derselbe Test unter Verwendung des hier gezeigten iterator-Konzepts ist im folgenden Listing dargestellt:

template<typename T>
requires iterator<T>
void Fill(T& t, int value)
{
    std::ranges::for_each(t, [value](auto& e) { e = value; });
}

Neben der Wiederverwendbarkeit des Konzepts ist die Lesbarkeit ein deutliches Plus. Wieder bringen Konzepte die Möglichkeit, Dinge zu benennen, die vor C++20 nicht mit einem Namen versehen werden konnten. Der aussagekräftige Name kann ein weiteres Argument für ein Konzept anstelle einer Ad-hoc-Einschränkung sein.

Obwohl das Konzept iterator recht einfach ist, sollte es unbedingt getestet werden.

Ein weiterer Vorteil von Konzepten ist, dass der Compiler den booleschen Wert zur Compile-Zeit bestimmt. Damit lassen sich Konzepte prinzipiell mittels static_assert testen. Die Tests für iterator sind im Listing zu sehen:

struct Valid final
{
    void begin() noexcept;
    void end() noexcept;
};

struct MissingFinal
{
    void begin();
    void end();
};

struct Empty
{
};

struct MissingNoexcept final
{
    void begin();
    void end();
};

struct DiffReturnType final
{
    void begin() noexcept;
    int  end() noexcept;
};

static_assert(iterator<Valid>);
static_assert(not iterator<MissingFinal>);
static_assert(not iterator<Empty>);
static_assert(not iterator<MissingNoexcept>);
static_assert(not iterator<DiffReturnType>);

Zunächst erstellt der Code die verschiedenen Datentypen, die die Einschränkung erfüllen oder einzelne nicht erfüllen. Danach kommen sie zusammen mit dem Konzept in einem static_assert zum Einsatz, und bei Bedarf wird der Ausdruck negiert.

Das Beispiel zeigt einen weiteren Vorteil gegenüber einer Ad-hoc-Einschränkung: Das Konzept lässt sich direkt im static_assert verwenden. Hängen die Einschränkungen via Ad-hoc-Einschränkung direkt an der Funktion, muss sie aufgerufen werden. In dem hier vorliegenden Fall ist das nicht ohne weiteren Trampolin-Code möglich. Außerdem ist es hilfreich, den Test der Einschränkungen von dem Test, ob eine Funktion bestimmte Einschränkungen erfüllt, zu trennen. Beispielsweise mag iterator wie gewünscht funktionieren, aber Fill hat andere Einschränkungen. Diesen Fehler bei einem Test mit einer Ad-hoc-Einschränkung zu separieren, ist schwieriger als bei zwei getrennten Tests für das Konzept und die Funktion.

Mit Konzepten lassen sich Einschränkungen formulieren, die sowohl wir Menschen als auch der Compiler als solche verstehen. Für die Formulierung von Konzepten stehen Type Traits oder der Requires-Ausdruck zur Verfügung. Speziell letzterer sorgt mit seinen Fähigkeiten dafür, dass die Einschränkungen wie gewöhnlicher Code aussehen, eine klare Verbesserung gegenüber "Substitution failure is not an error (SFINAE)".

Im zweiten Teil geht es darum, Konzepte anzuwenden. Dabei geht der Autor auch auf die Fehlermeldungen durch Konzepte ein.

Der vollständige Code steht auf GitHub zur Verfügung.

Andreas Fertig beschäftigt sich als Trainer und Berater mit C++. Sein Fokus liegt auf Embedded-Software.

(mai)