Requires Expressions in C++20 direkt verwenden

Requires Expressions in C++20 lassen sich auch als eigenständiges Feature verwenden, wenn ein Prädikat zur Compilezeit erforderlich ist.

In Pocket speichern vorlesen Druckansicht 1 Kommentar lesen
Lesezeit: 5 Min.
Von
  • Rainer Grimm
Inhaltsverzeichnis

In meinem letzten Artikel "Concepts mit Requires Expressions definieren" habe ich gezeigt, wie sich mit Requires Expressions Concepts definieren lassen. Requires Expressions können aber auch als eigenständiges Feature verwendet werden, wenn ein Prädikat zur Compilezeit erforderlich ist.

Typische Anwendungsfälle für Compilezeit-Prädikate sind static_assert, constexpr if oder eine Requires Clause. Ein Compilezeit-Prädikat ist ein Ausdruck, der zur Compilezeit einen booleschen Wert zurückgibt. Ich beginne diesen Artikel mit dem C++11 static_assert.

static_assert benötigt ein Compilezeitprädikat und eine Nachricht, die angezeigt wird, wenn das Compilezeitprädikat fehlschlägt. Mit C++17 ist die Nachricht optional. Mit C++20 kann dieses Compilezeitprädikat ein Requires Expression sein.

// staticAssertRequires.cpp

#include <concepts>
#include <iostream>

struct Fir {                   // (4)
    int count() const {
        return 2020;
    }
};

struct Sec {
    int size() const {
        return 2021;
    }
};

int main() {

    std::cout << '\n';
   
    Fir fir;
    static_assert(requires(Fir fir){ { fir.count() } -> std::convertible_to<int>; });     // (1)

    Sec sec;
    static_assert(requires(Sec sec){ { sec.count() } -> std::convertible_to<int>; });     // (2)

    int third;
    static_assert(requires(int third){ { third.count() } -> std::convertible_to<int>; }); // (3)

    std::cout << '\n';

}

Die Requires Expressions (Zeilen 1, 2 und 3) prüfen, ob das Objekt eine Memberfunktion count hat und ihr Ergebnis in int konvertierbar ist. Diese Prüfung ist nur für die Klasse First (Zeile 4) möglich. Die Prüfungen in den Zeilen (2) und (3) schlagen dagegen fehl.

Vielleicht möchtest du Code in Abhängigkeit von einer Compilezeitprüfung kompilieren? In diesem Fall ist das C++17-Feature constexpr if in Kombination mit Requires Expressions das passende Werkzeug.

constexpr if ermöglicht es, Quellcode bedingt zu kompilieren. Für die Bedingung kommt die Requires Expression ins Spiel. Alle Zweige der if-Anweisung müssen gültig sein.

Dank constexpr if kannst du Funktionen definieren, die ihre Argumente zur Compilezeit untersuchen und auf der Grundlage ihrer Analyse unterschiedliche Funktionalität erzeugen.

// constexprIfRequires.cpp

#include <concepts>
#include <iostream>

struct First {
    int count() const {
        return 2020;
    }
};

struct Second {
    int size() const {
        return 2021;
    }
};

template <typename T>
int getNumberOfElements(T t) {

    if constexpr (requires(T t){ { t.count() } -> std::convertible_to<int>; }) {
        return t.count();
    }
    if constexpr (requires(T t){ { t.size() } -> std::convertible_to<int>; }) {
        return t.size();
    }
    else return 42;

}

int main() {

    std::cout << '\n';
   
    First first;
    std::cout << "getNumberOfElements(first): "  << getNumberOfElements(first) << '\n';

    Second second;
    std::cout << "getNumberOfElements(second): "  << getNumberOfElements(second) << '\n';

    int third;
    std::cout << "getNumberOfElements(third): " << getNumberOfElements(third) << '\n';

    std::cout << '\n';

}

Die Zeilen (1) und (2) sind in diesem Codebeispiel entscheidend. In Zeile (1) bestimmen die Requires Expressions, ob die Variable t eine Memberfunktion count hat, die einen int zurückgibt. Entsprechend wird in Zeile (2) festgestellt, ob die Variable t eine Memberfunktion size besitzt. Die else-Anweisung in Zeile (3) kommt als Fallback zum Einsatz.

Zuallererst muss ich die Frage beantworten: Was ist eine Requires Clause?

Es gibt im Wesentlichen vier Möglichkeiten, ein Concept wie std::integral zu verwenden.

// conceptsIntegralVariations.cpp

#include <concepts>
#include <type_traits>
#include <iostream>      

template<typename T>                     // (1)                          
requires std::integral<T>                     
auto gcd(T a, T b) {
    if( b == 0 ) return a;
    else return gcd(b, a % b);
}

template<typename T>                     // (2)        
auto gcd1(T a, T b) requires std::integral<T> {  
    if( b == 0 ) return a; 
    else return gcd1(b, a % b);
}

template<std::integral T>                // (3)        
auto gcd2(T a, T b) {
    if( b == 0 ) return a; 
    else return gcd2(b, a % b);
}
                                           
auto gcd3(std::integral auto a,          // (4)
          std::integral auto b) { 
    if( b == 0 ) return a; 
    else return gcd3(b, a % b);
}

int main(){

    std::cout << '\n';

    std::cout << "gcd(100, 10)= "  <<  gcd(100, 10)  << '\n';
    std::cout << "gcd1(100, 10)= " <<  gcd1(100, 10)  << '\n';
    std::cout << "gcd2(100, 10)= " <<  gcd2(100, 10)  << '\n';
    std::cout << "gcd3(100, 10)= " <<  gcd3(100, 10)  << '\n';

    std::cout << '\n';

}

Dank dem Header <concepts> kann ich das Concept std::integral verwenden. Das Concept ist erfüllt, wenn T integral ist. Der Funktionsname gcd steht für den greatest-common-divisor-Algorithmus, der auf dem euklidischen Algorithmus basiert.

Hier sind die vier Möglichkeiten, Concepts zu verwenden:

  1. Requires Clause (Zeile 1)
  2. Trailing Requires Clause (Zeile 2)
  3. Constrained Template Parameter (Zeile 3)
  4. Abbreviated Function Template (Zeile 4)

Der Einfachheit halber gibt jedes Funktions-Template auto zurück. Es gibt einen semantischen Unterschied zwischen den Funktions-Templates gcd, gcd1, gcd2 und der Funktion gcd3. Im Fall von gcd, gcd1 oder gcd2 müssen die Argumente a und b denselben Typ besitzen. Das gilt nicht für die Funktion gcd3. Die Parameter a und b können unterschiedliche Typen haben, müssen aber beide das Concept std::integral erfüllen.

Die Funktionen gcd und gcd1 verwenden Requires Clauses.

Es gibt eine interessante Eigenschaft von Requires Clauses. Du kannst jedes Compilezeit-Prädikat als Ausdruck verwenden. In der folgenden Requires Clause prüfe ich, ob ein int als Nicht-Typ-Template-Parameter kleiner als 20 ist.

// requiresClause.cpp

#include <iostream>

template <unsigned int i>
requires (i <= 20)
int sum(int j) {
    return i + j;
}


int main() {

    std::cout << '\n';

    std::cout << "sum<20>(2000): " << sum<20>(2000) << '\n';
    // std::cout << "sum<23>(2000): " << sum<23>(2000) << '\n';  // ERROR

    std::cout << '\n';

}

Das in Zeile (1) verwendete Compilezeitprädikat verdeutlicht einen interessanten Punkt: Die Anforderung wird auf den Nicht-Typ i angewendet und nicht wie üblich auf einen Typ.

Wenn du die auskommentierte Zeile im Hauptprogramm verwendest, meldet der Clang-Compiler den folgenden Fehler:

Hier gibt es weitere Informationen über Nicht-Typ-Template-Parameter: "Alias Templates und Template Parameter".

Normalerweise verwendest du ein Concept in einer Requires Clause, aber es gibt weitere Anwendungsfälle: requires requires oder anonyme Concepts.

Ein anonymes Concept lässt sich definieren und direkt verwenden. In der Regel solltest du das aber nicht tun. Mit anonymen Concepts wird der Code schwerer lesbar und das Concept lässt sich nicht wiederverwenden.

template<typename T>
    requires requires (T x) { x + x; } 
auto add(T a, T b) { 
    return a + b; 

Das Funktions-Template definiert sein Concept ad-hoc. Das Funktions-Template add verwendet eine Requires Expression (requires(T x) { x + x; } ) innerhalb einer Requires Clause. Das anonyme Concept ist äquivalent zu dem folgenden Concept Addable.

template<typename T>
concept Addable = requires (T a, T b) {
    a + b; 
};

Folglich sind die folgenden vier Implementierungen des Funktions-Template add äquivalent zu der vorherigen:

template<typename T>  // requires clause
    requires Addable<T>
auto add(T a, T b) { 
    return a + b; 
}

template<typename T>  // trailing requires clause
auto add(T a, T b) requires Addable<T> { 
    return a + b; 
} 

template<Addable T>   // constrained template parameter
auto add(T a, T b){ 
    return a + b; 
} 
                     // abbreviated function template
auto add(Addable auto a, Addable auto b) {
    return a + b;
}

Zur Erinnerung: Die letzte Implementierung, die auf der Abbriviated Function Template Syntax basiert, kann mit Werten unterschiedlichen Typs umgehen.

Ich möchte es noch einmal betonen: Concepts sollten allgemeine Ideen umsetzen und du solltest ihnen einen selbsterklärenden Namen geben, damit sie wiederverwendet werden können. Sie sind von unschätzbarem Wert für das Verständnis von Code. Anonyme Concepts verhalten sich wie syntaktische Einschränkungen für Template Parameter und sollten vermieden werden.

Die Verwendung eines Concepts in static_assert(Concept<T>) ist im Wesentlichen ein Test, ob der Typ T Concept erfüllt. Im nächsten Artikel zeige ich, wie sich diese Idee anwenden lässt. ()