Concepts in C++20: Eine Evolution oder eine Revolution?

Modernes C++  –  30 Kommentare

Heute schließe ich meine Miniserie zu Concepts mit der Antwort zur philosophisch angehauchten Frage ab: Stellen Concepts eine Evolution oder eine Revolution in C+++ dar?

Wir alle ahnen es, für welche Begrifflichkeit Evolution und Revolution steht. Gerne will ich aber ein wenig genauer sein. Die Definitionen von re:invention sind kurz und bündig:

  • Evolution is defined as gradual change, adaptation, progression, metamorphosis.
  • Revolution is defined as forcible overthrow for an entirely new system…drastic, disruptive, far-reaching, momentous change.

Um es zu vereinfachen: Der entscheidende Unterschied zwischen einer Evolution und einer Revolution ist, ob die Veränderungen fließend (Evolution) oder sprunghaft (Revolution) erfolgen.

In meinen vorherigen Artikel gab es viele Diskussionen zu Concepts. Daher war ich auf eure Meinung zu meiner gestellten Frage sehr neugierig. Interessanterweise hatten die Antwort eine starke Tendenz zur Evolution. Ich jedoch neige mehr zur Revolution.

Welche Argumente sprechen nun für die Evolution beziehungsweise die Revolution?

Evolution

Saubere Abstraktion
(Bild: Alexas_Fotos @ Pixabay)

Vernünftig eingesetzt sollten Concepts das saubere Arbeiten mit generischem Code auf einer höheren Abstraktionsebene befördern. Auf längere Sicht könnte ich mir auch vorstellen, dass gerade die Standard-Concepts zunehmend idiomatisch werden sollten und dass damit auch die Interoperabilität und das modulare Arbeiten vor allem in größeren Teams robuster und weniger fehleranfällig gemacht werden kann, wenn mehr auf abstrakte Eigenschaften der Parameter-Klassen geprüft wird und weniger auf lediglich rein syntaktische "Ausrollbarkeit" in generischem Code.

Einfache Definition und sinnvolle Fehlermeldungen

Concepts können nichts, was man bisher – wenn auch gegebenenfalls sehr umständlich und aufwendig – nicht mit type-traits, SFINAE und static_assert hinbekommen hätte. Ihr Vorteil liegt in der einfachen Definition und sinnvollen Fehlermeldungen.

Unconstrained Placeholders

Seit C++11 können wir mithilfe von auto den Datentyp einer Variable von seinem Initialisierer ableiten:

auto integ = add(2000, 11);

std::vector<int> myVec{1, 2, 3};
for (auto v: myVec) std::cout << v << std::endl;

auto ist eine Art uneingeschränkter Platzhalter. Mit C++20 ist diese Ableitung des Datentyps auch mit eingeschränkten Platzhaltern (Concepts) möglich:

template<typename T>                                   
concept Integral = std::is_integral<T>::value;

Integral auto integ = add(2000, 11);

std::vector<int> myVec{1, 2, 3};
for (Integral auto v: myVec) std::cout << v << std::endl;

Um prägnant und evolutionär zu argumentieren: Eingeschränkte Platzhalter (Concepts) können überall dort verwendet werden, wo uneingeschränkte Platzhalter (auto) verwendbar sind.

Generische Lambdas

Seit C++14 lassen sich generische Lambdas (addLambda) einsetzen. Diese sind unter der Decke Funktions-Templates (addTemplate):

// addLambdaGeneric.pp

#include <iostream>

auto addLambda = [](auto fir, auto sec){ return fir + sec; };

template <typename T, typename T2>
auto addTemplate(T fir, T2 sec){ return fir + sec; }


int main(){

std::cout << addLambda(2000, 11.5); // 2011.5
std::cout << addTemplate(2000, 11.5); // 2011.5

}

Die Verwendung von auto in einer Funktionsdeklaration war in C++14 nicht möglich. Seit C++20 kannst du eingeschränkte (Concepts) und uneingeschränkte Platzhalter (auto) in der Funktionsdeklaration verwenden. Intern wird die Funktionsdeklaration zu einem Funktions-Template mit eingeschränkten (Concept) oder uneingeschränkten (auto) Platzhaltern:

// addUnconstrainedConstrained.cpp

#include <concepts>
#include <iostream>

auto addUnconstrained(auto fir, auto sec){
return fir + sec;
}

std::floating_point auto addConstrained(std::integral auto fir,
std::floating_point auto sec){
return fir + sec;
}

int main(){

std::cout << addUnconstrained(2000, 11.5); // 2011.5
std::cout << addConstrained(2000, 11.5); // 2011.5

}

Um meine Argumentation besser auf den Punkt zu bringen, besitzt die Funktion addConstrained eine sehr diskussionswürdige Signatur.

Revolution

Template-Anforderungen prüfen
(Bild: WikiImages @ Pixabay)

Zugegeben, Anforderungen an Templates lassen sich in C++11 – halbwegs elegant – bereits in der Template-Deklaration prüfen:

// requirementsCheckSFINAE.cpp

#include <type_traits>

template<typename T,
typename std::enable_if<std::is_integral<T>::value, T>:: type = 0>
T moduloOf(T t) {
return t % 5;
}

int main() {

auto res = moduloOf(5.5);

}

Das Funktions-Template moduloOf fordert, dass T integral sein soll. Falls T nicht integral ist und daher der Ausdruck std::integral<T>::value false ergibt, ist die fehlerhafte Substitution kein Fehler. Der Compiler entfernt diese konkrete Überladung aus der Menge aller möglichen Überladungen der Funktion moduloOf. Leider gibt es danach keine gültige Überladung mehr.

Diese Technik ist unter dem Name SFINAE bekannt. Das steht für "Substitution Failure Is Not An Error". Auf diese Technik gehe ich nur in fortgeschrittenen Schulungen zu Templates ein. Das gilt aber nicht für Concepts. Diese drücken ihre Intention direkt aus:

// requirementsCheckConcepts.cpp

#include <concepts>

std::integral auto moduloOf(std::integral auto t) {
return t % 5;
}

int main() {

auto res = moduloOf(5.5);

}
Die Definition von Templates ist deutlich einfacher

Dank der Abbreviated Funktion-Template Syntax wird die Definition von Templates in C++20 zum Kinderspiel. Ich habe bereits den neuen Syntactic Sugar in der Funktionsdeklaration von addConstrained und mudolOf vorgestellt. Daher lasse ich das Beispiel in diesem Abschnitt aus.

Semantische Kategorien

Concepts stehen nicht für syntaktische Einschränkungen, sondern für semantische Kategorien. Addable ist ein Concept, das eine syntaktische Einschränkung repräsentiert.

template<typename T>
concept Addable = has_plus<T>; // bad; insufficient

template<Addable N> auto algo(const N& a, const N& b) // use two numbers
{
// ...
return a + b;
}

int x = 7;
int y = 9;
auto z = algo(x, y); // z = 16

std::string xx = "7";
std::string yy = "9";
auto zz = algo(xx, yy); // zz = "79"

Addable verhält sich nicht erwartungsgemäß. Das Funktions-Template algo sollte Argumente annehmen können, die eine Zahl modellieren und nicht lediglich den +-Operator unterstützen. Konsequenterweise lassen sich zwei Strings als Argument verwenden. Dies ist sehr fragwürdig, da die Addition kommutativ sein sollte. String-Konkatenation ist es aber nicht:

"7" + "9" != "9" + "7".

Die Lösung liegt auf der Hand. Definiere das Concept Number. Es ist eine semantische Kategorie wie Equal, Callable, Predicate oder Monade.

Meine Antwort

Natürlich lassen sich gewichtige Argumente für die evolutionären Schritte oder einen revolutionären Sprung mit Concepts finden. Dank der semantischen Kategorien tendiere ich deutlich zur Revolution. Concepts wie Number, Equal oder Ordering erinnern mich an die platonische Welt der Ideen. Für mich ist es revolutionär, dass wir dank Concepts unsere Programme in diesen Kategorien analysieren können.

Wie geht's weiter

Die Ranges-Bibliothek, die ich in meinem nächsten Artikel genauer vorstelle, ist der erste Konsument der Concepts. Ranges unterstützen Algorithmen, die

  • auf dem ganzen Container arbeiten.
  • lazy evaluiert werden.
  • komponiert werden können.