C++20: Coroutinen – ein erster Überblick
C++20 bietet vier Features an, die die Art und Weise ändern, wie wir über C++ denken und es einsetzen: Concepts, die Ranges-Bibliothek, Coroutinen und Module. Zu den Concepts und der Ranges-Bibliothek gibt es schon einige Artikel. Daher geht es nun an die Coroutinen.
- Rainer Grimm
C++20 bietet vier Features an, die die Art und Weise ändern, wie wir über C++ denken und es einsetzen: Concepts, die Ranges-Bibliothek, Coroutinen und Module. Zu den Concepts und der Ranges-Bibliothek habe ich schon einige Artikel verfasst. Dieser Artikel dient als Startpunkt, um in weiteren tiefer in die Coroutinen einzutauchen.
Coroutinen sind Funktionen, deren Ausführung sich anhalten und wieder aufnehmen lässt. Dabei behalten sie ihren Zustand. Die Evolution der Funktionen in C++ geht damit einen Schritt weiter. Was ich als neue Idee in C++20 "verkaufe", ist tatsächlich schon sehr angestaubt. Auf Melvin Conway geht der Name Coroutine zurück. Er verwendet ihn 1963 in seiner Veröffentlichung zur Konstruktion eines Compilers. Donald Knuth bezeichnete Funktionen als einen Spezialfall von Coroutinen.
Die zwei neuen Schlüsselworte co_await
und co_yield
erweitern die Ausführung von Funktionen um zwei neue Ideen:
- Dank
co_await expression
ist es möglich, die Ausführung einer Funktion anzuhalten und wieder aufzunehmen. Wenn duco_await expression
in einer Funktionfunc
verwendest, blockiert der AufrufgetResult = func()
nicht, falls das Ergebnis noch nicht zur Verfügung steht. Damit wird ein ressourcenintensives Blocken zu einem ressourcenfreundlichen Warten. co_yield expression
erlaubt es, eine Generatorfunktion zu schreiben. Diese gibt bei jedem Aufruf einen Wert zurück. Eine solche Funktion ist eine Art Datenstrom, von dem sich sukzessive Werte auslesen lassen. Dieser Datenstrom kann auch unendlich sein. Jetzt befinden wir uns mitten in der Bedarfsauswertung (lazy evaluation).
Bevor ich zum Einstieg eine Generatorfunktion vorstellen möchte, um die Unterschiede einer Funktion und Coroutine auf den Punkt zu bringen, möchte ich mit ein paar Worten auf die Evolution von Funktionen eingehen.
Evolution von Funktionen
Das folgende Programm zeigt in vereinfachender Form die verschiedenen Stufen in der Evolution einer Funktion:
// functionEvolution.cpp
int func1() {
return 1972;
}
int func2(int arg) {
return arg;
}
double func2(double arg) {
return arg;
}
template <typename T>
T func3(T arg) {
return arg;
}
struct FuncObject4 {
int operator()() { // (1)
return 1998;
}
};
auto func5 = [] {
return 2011;
};
auto func6 = [] (auto arg){
return arg;
};
int main() {
func1(); // 1972
func2(1998); // 1998
func2(1998.0); // 1998.0
func3(1998); // 1998
func3(1998.0); // 1998.0
FuncObject4 func4;
func4(); // 1998
func5(); // 2011
func6(2014); // 2014
func6(2014.0); // 2014
}
- Seit dem ersten C-Standard (1972) kennen wir Funktionen:
func1
. - Mit dem ersten C++-Standard (1998) wurden Funktionen deutlich mächtiger:
- Überladen von Funktionen:
func2
. - Funktions-Templates:
func3
. - Funktionsobjekte:
func4
. Diese werden gerne fälschlicherweise Funktoren genannt. Sie sind dank des überladenen Aufrufoperators (operator ()
) Objekte, die sich aufrufen lassen. Das zweite Paar runder Klammern in der Zeile (1) steht für die Funktionsparameter.
- Überladen von Funktionen:
- C++11 führte zur Erweiterung um Lambda-Funktionen:
func5
. - Mit C++14 können Lambda-Funktionen generisch sein:
func6
.
Jetzt geht die Evolution einen Schritt weiter. Generatoren sind spezielle Coroutinen.
Generatoren
Im klassischem C++ lässt sich ein gieriger (greedy) Generator implementieren.
Ein gieriger Generator
Das folgende Programm ist so einfach wie möglich gehalten. Die Funktion getNumbers
gibt alle Zahlen von begin
bis end
um inc
inkrementiert zurück. begin
muss kleiner als end
und inc
positiv sein:
// greedyGenerator.cpp
#include <iostream>
#include <vector>
std::vector<int> getNumbers(int begin, int end, int inc = 1) {
std::vector<int> numbers; // (1)
for (int i = begin; i < end; i += inc) {
numbers.push_back(i);
}
return numbers;
}
int main() {
std::cout << std::endl;
const auto numbers= getNumbers(-10, 11);
for (auto n: numbers) std::cout << n << " ";
std::cout << "\n\n";
for (auto n: getNumbers(0, 101, 5)) std::cout << n << " ";
std::cout << "\n\n";
}
Ich muss zugeben, dass ich mit getNumbers
das Rad nicht neu erfinde, denn die Funktion std::iota
existiert genau für diese Aufgabe. Die Ausgabe des Programms sollte keine Überraschung bergen:
Zwei Beobachtungen zum Programm sind entscheidend. Zum einen enthält der Vektor numbers
in Zeile (1) immer alle Werte. Das gilt selbst dann, wenn ich nur an den ersten fünf Elementen eines Vektors mit 1000 Elementen interessiert bin. Zum anderen lässt sich die Funktion einfach in einen faulen (lazy) Generator transfomieren.
Ein "lazy" Generator
Dies ist bereits der Generator, der Bedarfsauswertung umsetzt:
// lazyGenerator.cpp
#include <iostream>
#include <vector>
generator<int> generatorForNumbers(int begin, int inc = 1) {
for (int i = begin;; i += inc) {
co_yield i;
}
}
int main() {
std::cout << std::endl;
const auto numbers= generatorForNumbers(-10); // (2)
for (int i= 1; i <= 20; ++i) std::cout << numbers << " "; // (4)
std::cout << "\n\n";
for (auto n: generatorForNumbers(0, 5)) std::cout << n << " "; // (3)
std::cout << "\n\n";
}
Während die Funktion getNumbers
in der Datei greedGenerator
.cpp
einen std::vector
zurückgibt, gibt die Coroutine generatorForNumbers
in lazyGeneraror
.cpp
einen Generator zurück. Die Generatoren numbers
in Zeile (2) oder generatorForNumbers
(0, 5)
in Zeile (3) geben wiederum auf Anfrage neue Werte zurück. Die range-basierte for-Schleife stößt diese Anfrage an. Um es genauer auszudrücken: Die Anfrage an die Coroutine gibt den Wert i
mittels co_yield i
zurück und legt sich dann schlafen. Falls ein neuer Wert angefordert wird, wird die Coroutine aufgeweckt und setzt ihre Ausführung genau an dieser Stelle fort.
Der Ausdruck generatorForNumbers(0, 5)
in Zeile (3) stellt eine direkte Verwendung des Generators dar. Einen Punkt möchte ich explizit hervorheben. Die Coroutine generatorForNumbers
erzeugt einen unendlichen Datenstrom, denn die for
-Schleife besitzt keine Endbedingung. Ein unendlicher Datenstrom ist sinnvoll, wenn ich nur endlich viele Elemente wie in Zeile (4) anfordere. Das gilt nicht für die Zeile (3), da sie keine Endbedingung besitzt. Konsequenterweise beendet sich diese Schleife nie.
Wie geht's weiter?
Mit C++20 erhalten wir keine konkreten Coroutinen, sondern ein Framework für die Implementierung von Coroutinen. Du kannst dir denken, dass ich dazu einiges zu schreiben habe.
In eigener Sache: First Virtual Meetup
Ich freue mich, dass wir es kurzfristig geschafft haben, meinen Vortrag für die Münchner C++ User Group zu virtualisieren. Hier ist die offizielle Einladung zu dem englischen Vortrag:
"Help us fight social isolation and join us next Thursday for our first-ever virtual meetup! @rainer_grimm will be talking about Concepts in C++20. March 26, 19:00 (CET).
Check out the full event description at meetup.com/MUCplusplus. The stream is open for everyone, you don't need to register on meetup for this one." ()