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.

In Pocket speichern vorlesen Druckansicht 59 Kommentare lesen
Lesezeit: 7 Min.
Von
  • Rainer Grimm
Inhaltsverzeichnis

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 du co_await expression in einer Funktion func verwendest, blockiert der Aufruf getResult = 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.

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.
  • 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.

Im klassischem C++ lässt sich ein gieriger (greedy) Generator implementieren.

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.

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.

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.

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." ()