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.
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.
Bausteine eines Konzepts
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).
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.
Einsatzorte für Konzepte
Konzepte beziehungsweise Einschränkungen sind in fünf verschiedenen Bereichen einsetzbar (s. Abb. 2).
Type Constraint
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.
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.
Anforderung oder Einschränkung
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.
Ad-hoc-Einschränkung
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.
Testen von Konzepten
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.
Fazit und Ausblick
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 [3].
Andreas Fertig beschäftigt sich als Trainer und Berater mit C++. Sein Fokus liegt auf Embedded-Software.
(mai [4])
URL dieses Artikels:
https://www.heise.de/-6136234
Links in diesem Artikel:
[1] https://www.heise.de/hintergrund/C-20-Konzepte-Robusterer-generischer-Code-mit-Konzepten-6136234.html
[2] https://www.heise.de/hintergrund/C-20-Konzepte-Neue-Wege-mit-Konzepten-6155195.html
[3] https://github.com/andreasfertig/heise-2021-07-cpp20-concepts-teil-1/
[4] mailto:mai@heise.de
Copyright © 2021 Heise Medien