Aus Linux-Magazin 04/2023

Modernes C++ in der Praxis – Folge 67

© Dudlajzov / 123RF.com

Mit Concepts, Ranges, Modulen und Coroutinen verändert C++20 die Art und Weise, wie man modernes C++ schreibt, ähnlich fundamental wie zuvor die Standards C++98 und C++11.

Bevor sich dieser und folgende Artikel näher mit dem Standard C++20 beschäftigen, ist es sinnvoll, einen Moment innezuhalten und der Frage nachzugehen, welche C++-Standards bisher überhaupt veröffentlicht wurden. Was hat sie jeweils ausgemacht? Die Antwort darauf hilft, C++20 in den richtigen historischen Kontext zu stellen.

C++ ist etwa 40 Jahre alt. Wie es sich in den letzten 25 Jahren entwickelt hat, lässt sich in erster Näherung der Tabelle “Jüngere C++-Historie” entnehmen.

C++98

C++11

C++14

C++17

C++20

Jahr

1998

2011

2014

2017

2020

Features

Templates, STL mit Containern und Algorithmen, Strings, I/O-Streams,

Move-Semantik, Unified Initialization, »auto« und »decltype«, Lambda-Funktionen, »constexpr()«, Multithreading und Memory-Modell, reguläre Ausdrücke, Smart Pointers, Hash-Tabellen, »std::array«

Reader-Writer-Locks, generische Lambda-Funktionen

Fold-Ausdrücke, »if constexpr()«, Structured Binding, »std::string_view«, Parallelalgorithmen der STL, Dateisystembibliothek, »std::any«, »std::optional«, »std::variant«

Concepts, Modules, Ranges-Bibliothek, Coroutinen

Standards

Ende der 1980er-Jahre schrieben Bjarne Stroustrup und Margaret A. Ellis ihr berühmtes Buch “Annotated C++ Reference Manual” (ARM). Damit verfolgten sie zwei Ziele: Erstens gab es damals viele unabhängige C++-Implementierungen, und das ARM definierte in dieser Situation verbindlich die Funktionalität von C++. Zweitens bildete das ARM die Grundlage für den ersten C++-Standard, C++98 (ISO/IEC 14882). Er implementierte einige wichtige Funktionalitäten von C++, darunter Templates und die Standard Template Library (STL) mit ihren generischen Containern und Algorithmen, Strings und IO-Streams.

Mit C++03 (ISO/IEC 14882:2003) erhielt C++98 eine technische Korrektur, die so geringfügig ausfiel, dass sie keinen Platz in der Tabelle “Jüngere C++-Historie” beansprucht. C++03, das C++98 enthält, bezeichnet man als Legacy C++.

C++11 steht für den nächsten C++-Standard, für den sich zusammen mit C++14 und C++17 der Name modernes C++ etabliert hat. C++11 führte viele neue Funktionalitäten ein, die die Art und Weise, wie wir C++ programmieren, grundlegend veränderten. So kennt C++11 Move-Semantik, eine automatische Typbestimmung mit »auto«, Lambda-Ausdrücke sowie konstante Ausdrücke. Darüber hinaus erweiterte diese Version C++ um Smart Pointer und Hash-Tabellen. Darüber hinaus enthielt C++11 zum ersten Mal das Speichermodell als fundamentale Basis für Threading und eine Threading-API.

Bei C++14 handelt es sich um einen kleineren C++-Standard. Er erweiterte C++ um Reader-Writer-Locks, verallgemeinerte Lambda-Ausdrücke und mächtigere »constexpr()«-Funktionen.

C++17 ist weder groß noch klein. Er besitzt zwei herausragende Features: die parallele STL und das standardisierte Dateisystem. Etwa 80 Algorithmen der STL lassen sich unter einer sogenannten Execution Policy ausführen. Das heißt, ein Aufruf »std::sort(std::execute::par, vec.begin(), vec.end())« ist ein Hinweis für die C++-Implementierung, den Container »vec« parallel zu sortieren. Zusätzlich kann man angeben, dass die Sortierung sequenziell (»std::execution::seq«) oder vektorisiert (»std::execution::par_unseq«) erfolgen soll. Wie bei C++11 war auch bei C++17 die Boost-Bibliothek [1] sehr einflussreich. Das Boost-Dateisystem und die drei neuen Container »std::optional«, »std::variant« und »std::any« wurden mit C++17 standardisiert.

C++20 wird die Art und Weise, wie wir C++ programmieren, ebenso grundlegend verändern wie zuvor C++11. Das liegt insbesondere an vier bedeutenden Neuerungen: Concepts, Ranges, Coroutinen und Modulen.

Concepts

Der Grundgedanke der generischen Programmierung mit Templates besteht darin, Funktionen und Klassen zu definieren, die man mit verschiedenen Typen verwenden kann. Oft passiert es, dass Sie ein Template mit dem falschen Typ instantiieren. Das Ergebnis sind in der Regel ein paar Seiten kryptischer Fehlermeldungen. Das ändert sich mit den Concepts: Sie ermöglichen es, Anforderungen an Templates zu definieren, die der Compiler überprüfen kann. Damit revolutionieren Concepts die Art und Weise, wie wir generischen Code verstehen und ihn schreiben.

Das zeigt sich besonders in den Details. So sind nun die Anforderungen an Templates ein Teil der Schnittstelle. Das Überladen von Funktionen oder die Spezialisierung von Templates kann auf Concepts basieren. Daraus resultieren deutlich verbesserte Fehlermeldungen, da der Compiler die Anforderungen an den Template-Parameter mit den tatsächlichen Argumenten vergleicht. Hinzu kommen weitere Vorteile von Concepts: Man kann vordefinierte Concepts verwenden oder eigene definieren. Die Verwendung von »auto« und Concepts ist vereinheitlicht. Verwendet eine Funktionsdeklaration ein Concept, dann wird sie automatisch zu einem Funktions-Template. Das macht das Schreiben solcher Templates ebenso einfach wie das Schreiben einer Funktion.

Der Codeausschnitt in Listing 1 zeigt die Definition und die Verwendung des einfachen Concepts »Integral«. Es verlangt von seinem Typ-Parameter »T«, dass »std::is_integral<T>::value« gilt. Die Funktion »std::is_integral<T>::value« der Type-Traits-Bibliothek [2] überprüft zur Compile-Zeit, ob »T« integral ist. Ergibt »std::is_integral<T>::value« den Wert »true«, ist alles in Ordnung. Anderenfalls beendet der Compiler seine Arbeit.

Listing 1

Ein Concept verwenden

template<typename T>
concept bool Integral(){
  return std::is_integral<T>::value;
}
Integral auto gcd(Integral auto a, Integral auto b){
  if ( b == 0 ) return a;
  else return gcd(b, a % b);
}

Der GCD-Algorithmus ermittelt auf Basis des euklidischen Algorithmus [3] den größten gemeinsamen Teiler zweier Zahlen. In Listing 1 kommt die sogenannte Abbreviated Function Template Syntax zum Einsatz, um »gcd« zu definieren. Das wiederum verlangt von seinen Argumenten und seinem Rückgabetyp, dass sie das Concept »Integral« unterstützen. Bei »gcd« handelt es sich um ein Funktions-Template, das Anforderungen an seine Argumente und seinen Rückgabewert stellt. Entfernt man den Syntactic Sugar [4] aus der »gcd«-Implementierung in Listing 1, kommt die wahre Natur des Funktions-Templates zum Vorschein (Listing 2).

Listing 2

Funktions-Template gcd

template<typename T>
requires Integral<T>()
T gcd(T a, T b){
    if( b == 0 ) return a;
    else return gcd(b, a % b);
}

Die Ranges-Bibliothek

Die Ranges-Bibliothek ist der erste Kunde von Concepts. Ranges unterstützt Algorithmen, die direkt auf dem Container operieren können und sich “lazy” auswerten oder komponieren lassen. Kurz gesagt unterstützt die Ranges-Bibliothek also funktionale Muster. Doch Code sagt mehr als Worte: Das Programm in Listing 3 zeigt die Funktionskomposition mit dem Pipe-Symbol.

Listing 3

Funktionskomposition

#include <vector>
#include <ranges>
#include <iostream>
int main(){
  std::vector<int> ints{0, 1, 2, 3, 4, 5};
  auto even = [](int i){ return 0 == i % 2; };
  auto square = [](int i) { return i * i; };
  for (int i : ints | std::view::filter(even) | std::view::transform(square)) {
    std::cout << i << ' '; // 0 4 16
  }
}

Die Lambda-Funktion »even« gibt zurück, ob »i« gerade ist, die Lambda-Funktion »square« bildet »i« auf sein Quadrat ab. Der Rest des »main()«-Programms ist eine Funktionskomposition, die von links nach rechts zu lesen ist: Wende auf jedes Element von »ints« den Filter »even« an und bilde die verbleibenden Elemente auf ihr Quadrat ab (Zeile 8).

Coroutinen

Bei Coroutinen handelt es sich um verallgemeinerte Funktionen, die man unter Beibehaltung ihres Zustands anhalten und fortsetzen kann. Sie sind der typische Weg, um ereignisgesteuerte Anwendungen zu schreiben. Solche Anwendungen können Simulationen, Spiele, Server, Benutzeroberflächen oder sogar Algorithmen sein. Coroutinen kommen in der Regel auch für kooperatives Multitasking zum Einsatz.

Mit C++20 erhält C++ zwar keine konkreten Coroutinen, dafür aber ein Framework, mit dem sie sich schreiben lassen. Es umfasst mehr als 20 Funktionen, die man entweder implementieren muss oder kann. So lassen sich Coroutinen exakt auf individuelle Bedürfnisse zuschneiden.

Das Programm in Listing 4 (nur ein Codeschnipsel) verwendet einen Generator, der einen unendlichen Datenstrom erzeugt. Die Funktion »getNext()« ist eine Coroutine, da sie das Schlüsselwort »co_yield« verwendet. »getNext()« enthält eine Endlosschleife, die den auf »co_yield« folgenden Wert zurückgibt. Ein Aufruf von »next()« setzt die Coroutine fort, der folgende Aufruf von »getValue()« liefert den Wert. Nach dem Aufruf von »getNext()« legt die Coroutine bis zum nächsten »next()«-Aufruf eine Pause ein.

Listing 4

Unendlicher Zahlenstrom

Generator<int> getNext(int start = 0, int step = 1){
  auto value = start;
  for (int i = 0;; ++i){
    co_yield wert; // 1
    wert += schritt;
  }
}
int main() {
  std::cout << std::endl;
  std::cout << "getNext():";
  auto gen = getNext();
  for (int i = 0; i <= 10; ++i) {
    gen.next();
    std::cout << " " << gen.getValue();
  }
  std::cout
 << "\n\n";
  std::cout << "getNext(100, -10):";
  auto gen2 = getNext(100, -10);
  for (int i = 0; i <= 20; ++i) {
    gen2.next();
    std::cout << " " << gen2.getValue();
  }
  std::cout << std::endl;
}

Das Beispiel enthält eine große Unbekannte, den Rückgabewert »Generator<int>« der Funktion »getNext()«. Hier beginnen die komplizierten Aspekte von Coroutinen, die Inhalt eines zukünftigen Artikels dieser Serie sein werden. Abbildung 1 zeigt die Ausgabe des Programms.

Abbildung 1: Die Coroutine &raquo;Generator&laquo; im Einsatz.

Abbildung 1: Die Coroutine »Generator« im Einsatz.

Module

Module versprechen eine Menge. Sie stehen für kürzere Kompilierzeiten, reduzieren die Notwendigkeit, Makros zu definieren, und drücken die logische Struktur des Codes aus. Sie ersetzen Header-Dateien und vermeiden hässliche Makro-Workarounds. Listing 5 stellt das einfache Modul »math« vor.

Listing 5

Modul math

export module math;
export int add(int fir, int sec) {
  return fir + sec;
}

Der Ausdruck »export module math« in der ersten Zeile ist die Moduldeklaration. Das Einfügen von »export« vor der Funktion »add« (zweite Zeile) exportiert die Funktion. Listing 6 zeigt die einfache Verwendung des Moduls »math«. Der Ausdruck »import math« importiert hier das Modul und macht die exportierten Namen im aktuellen Bereich sichtbar.

Listing 6

math verwenden

import math;
int main() {
  add(2000, 20);
}

C++20-Goodies

Neben den bis hierhin vorgestellten “großen Vier” hat C++20 noch etliche bemerkenswerte Neuerungen zu bieten.

Dazu zählt der Dreiwegevergleichsoperator, auch als Spaceship-Operator bekannt. Er erzeugt automatisch die sechs Vergleichsoperatoren für einen benutzerdefinierten Datentyp. Die Keywords »consteval« und »constinit« sowie die verbesserten Templates machen die Programmierung zur Compile-Zeit noch mächtiger. »std::span« unterstützt den sicheren Zugriff auf zusammenhängende Speicherblöcke wie ein C-Array. CC++20 erweitert die Zeitbibliothek um Kalender- und Zeitzonenfunktionen.

Das Formatieren von Strings geht dank »std::format« flott von der Hand. Die einfache Synchronisierung von Threads lässt sich mit Semaphoren oder Latches und Barrieren direkt umsetzen. Ein verbesserter Thread, den man unterbrechen kann, synchronisierte Ausgabeströme sowie deutlich mächtigere atomare Variablen, die sich einfach zur Thread-Synchronisation einsetzen lassen, runden die Funktionalität von C++20 ab.

Auf die Suche in die Breite folgt demnächst ein Artikel zu Concepts: Damit startet dann die Suche in die Tiefe. (jcb/jlu)

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 4 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Nach oben