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

Seite 3: Anforderung oder Einschränkung

Inhaltsverzeichnis

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.