C++20: Zwei Extreme und die Rettung dank Concepts

Modernes C++  –  36 Kommentare

Im letzten Blog-Artikel habe ich meinen Überblick zu C++20 abgeschlossen. Jetzt ist es an der Zeit, die Features genauer unter die Lupe zu nehmen. Hierfür gibt es keinen besseren Einstieg als Concepts.

Zugegebenermaßen, bin ich ein großer Fan von Concepts und daher parteiisch. Zuerst möchte ich Concepts motivieren.

Zwei Extreme

Bis C++20 hatten wir in C++ zwei diametrale Optionen, mithilfe von Funktionen und Klassen Abstraktionen zu schaffen. Funktionen oder Klassen ließen sich für konkrete Datentypen oder generische Datentypen definieren. Im zweiten Fall nennen wir diese Funktions- oder Klassen-Templates. Warum sind beide Wege falsch?

Zu spezifisch

Es ist nahezu eine Herkulesaufgabe, für jeden konkreten Datentyp eine Funktion oder Klasse zu definieren. Um diese Last von unseren Schultern zu nehmen, kommen Typkonvertierungen ins Spiel. Doch was wie eine Rettung scheint, entpuppt sich oft als Fluch:

// tooSpecific.cpp

#include <iostream>

void needInt(int i){
std::cout << "int: " << i << std::endl;
}

int main(){

std::cout << std::boolalpha << std::endl;

double d{1.234}; // (1)
std::cout << "double: " << d << std::endl;
needInt(d); // (2)

std::cout << std::endl;

bool b{true}; // (3)
std::cout << "bool: " << b << std::endl;
needInt(b); // (4)

std::cout << std::endl;

}

Im ersten Fall (Zeile 1), starte ich mit einem double- und ende mit einem int-Wert (Zeile 2). Im zweiten Fall (Zeile 3) starte ich mit einem bool- und ende wieder mit einem int-Wert (Zeile 4).

  • Narrowing Conversion

Der Aufruf von getInt(int a) mit einem double-Wert verursacht "narrowing conversion". Dieses ist eine Konvertierung mit Verlust der Datengenauigkeit. Das war sicherlich nicht die Absicht des Autors.

  • Integral Promotion

Anders herum ist es aber auch nicht besser. Wird getInt(int a) mit einem bool-Wert aufgerufen, wird dieser auf einen int-Wert aufgeblasen. Überrascht? Viele C++ Entwickler wissen nicht, was passiert, wenn zwei Wahrheitswerte addiert werden:

template <typename T>
auto add(T first, T second){
return first + second;
}

int main(){
add(true, false);
}

C++ Insights zeigt die ganze Wahrheit:

Die Template-Instanziierung des Funktions-Templates add erzeugt eine vollständige Spezialisierung (Zeilen 6 bis 12), die den Rückgabetyp int besitzt.

Meine feste Überzeugung ist es, dass wir nur aus praktischen Gründen die ganze Magie der Typkonvertierungen in C/C++ besitzen, um mit der Unzulänglichkeit umgehen zu können, dass Funktionen nur spezifische Datentypen annehmen können.

Wenden wir nun die zweite Option an und nutzen keine spezifischen, sondern generische Datentypen. Vielleicht sind ja gerade Templates unsere Rettung.

Zu generisch

Hier ist mein erster Versuch: Sortieren ist ein ganz allgemeine Idee. Daher sollte sie auf jeden Container anwendbar sein, falls sich die Elemente des Containers sortieren lassen. Lasse mich std::sort auf eine std::list anwenden:

// sortList.cpp

#include <algorithm>
#include <list>

int main(){

std::list<int> myList{1, 10, 3, 2, 5};

std::sort(myList.begin(), myList.end());

}

Wow! Dies passiert, wenn du versuchst, das kleine Programm zu übersetzen:

Diese Fehlermeldung will ich nicht dechiffrieren. Was läuft hier falsch? Vielleicht hilft ein genauerer Blick auf die verwendete Überladung von std::sort.

template< class RandomIt >
void sort( RandomIt first, RandomIt last );

std::sort verwendet den seltsam klingenden Name RandomIt. Es steht für einen random access iterator. Dies ist der Grund für die überwältigende Fehlermeldung, für die Templates berühmt-berüchtigt sind. Eine std::list bietet nur einen bidirectional iterator an, aber std::sort benötigt einen random access iterator. Die Struktur einer std::list macht diese Einschränkung offensichtlich.

Wenn du genauer die Dokumentation zu std::sort auf cppreference.com studierst, findest du etwas sehr Interessantes: Typanforderungen an std::sort.

Concepte als Rettung

Concepts sind die Rettung, denn sie definieren semantische Einschränkungen auf Template-Parametern.

Hier sind die bereits erwähnten Typanforderungen zu std::sort.

Die Typanforderungen an std::sort sind Concepte. Mein Artikel "C++20: Die vier großen Neuerungen" gibt eine kompakte Einführung zu Concepts. Insbesondere fordert std::sort einen LegacyRandomAccessIterator. Darauf will ich gerne einen genaueren Blick werfen. Das Beispiel von cppreference.com habe ich ein wenig poliert:

template<typename It>
concept LegacyRandomAccessIterator =
LegacyBidirectionalIterator<It> && // (1)
std::totally_ordered<It> &&
requires(It i, typename std::incrementable_traits<It>::difference_type n) {
{ i += n } -> std::same_as<It&>; // (2)
{ i -= n } -> std::same_as<It&>;
{ i + n } -> std::same_as<It>;
{ n + i } -> std::same_as<It>;
{ i - n } -> std::same_as<It>;
{ i - i } -> std::same_as<decltype(n)>;
{ i[n] } -> std::convertible_to<std::iter_reference_t<It>>;
};

Dies ist die entscheidende Beobachtung. Ein Datentyp It unterstützt das Concept LegacyRandomAccessIterator genau dann, wenn er das Concept LegacyBidirectionalIterator (Zeile 1) und die weiteren Anforderungen unterstützt. Zum Beispiel sagt die Anforderung in Zeile 2 für einen Wert des Datentyps It aus: { i += n } muss gültig sein und eine Referenz auf It zurückgeben. Um mit meiner Story zum Abschluss zu kommen. std::list unterstützt nur einen LegacyBidirectionalIterator.

Zugegeben, dieser Abschnitt war sehr technisch. Jetzt folgt die Praxis. Mit Concepts wirst du eine einfache Fehlermeldung wie die folgende erhalten, wenn du std::sort mit einer std::list erhältst:

Sorry, die Fehlermeldung war eine Ente, denn kein Compiler setzt zum jetzigen Zeitpunkt die C++20-Syntax für Concepts vollständig um. MSVC 19.23 unterstützt Concepts teilweise, GCC eine vorherige Version von Concepts. cppreference.com liefert mehr Details zur aktuellen Compilerunterstützung von Concepts.

Habe ich gesagt, dass GCC ein vorherige Version von Conceps unterstützt?

Die lange, lange Geschichte

Das erste Mal, als ich um 2005/2006 mit Concepts in Berührung kam, erinnerten sie mich an Haskells Typklassen. Typklassen in Haskell sind Interface für ähnliche Datentypen. Das Bild zeigt einen Ausschnitt aus Haskells Typklassenhierachie:

Doch C++ Concepts unterscheiden sich von Haskells Typklassen. Hier sind ein paar Beobachtungen.

  • In Haskell muss ein Datentyp eine Instanz einer Typklasse sein. In C++20 muss ein Datentyp die Anforderungen eines Concepts erfüllen.
  • Concepts lassen sich auch auf "non-type parameter" anwenden. Zum Beispiel kann die Zahl 5 als non-type parameter verwendet werden. Wenn du ein std::array von ints mit 5 Elementen haben möchtest, wendest du diesen non-type parameter an: std::array<int, 5> myVec.
  • Concepts besitzen keine Laufzeitkosten.

Ursprünlich sollten Concepts bereits das große Feature von C++11 sein. In dem Standardisierungsmeeting in Frankfurt wurden sie aber im Juli 2009 entfernt. Das Zitat von Bjarne Stroustrup spricht für sich selbst: "The C++Ox concept design evolved into a monster of complexity." Ein paar Jahre später war der nächste Versuch, Concepts zu standardisieren, wieder erfolglos. Concept lite wurden aus C++17 entfernt. Nun bekommen wir sie aber mit C++20.

Wie geht's weiter?

Mit meinem nächsten Artikel knüpfe ich natürlich an Concepts an. Ich werde viele Beispiele zu semantischen Einschränkungen auf Template-Parameter vorstellen.