Ein generischer Datenstrom mit Coroutinen in C++20
In meinem letzten Artikel "Ein undendlicher Datenstrom dank Coroutinen in C++20" dieser Miniserie zur Anwendung von Coroutinen stellte ich den Arbeitsablauf vor. Heute nutze ich das generische Potenzial des Datenstroms
- Rainer Grimm
In meinem letzten Artikel "Ein unendlicher Datenstrom dank Coroutinen in C++20" dieser Miniserie zur Anwendung von Coroutinen stellte ich den Arbeitsablauf vor. Heute nutze ich das generische Potenzial des Datenstroms.
Voraussetzung zum Verständnis ist die Lektüre des vorherigen Artikels "Ein unendlicher Datenstrom dank Coroutinen in C++20". Er erklärt detailliert den Arbeitsablauf des unendlichen Generators, basierend auf dem neuen Schlüsselwort co_yield
. Bisher hat sich diese Miniserie mit den neuen Schlüsselwörtern co_return
und co_yield
beschäftigt, die eine normale Funktion in eine Coroutine transformieren. Nun werde ich auf das anspruchsvollste Schlüsselwort co_await
genauer eingehen.
co_return
- Einfache Futures mit Coroutinen implementieren
- Lazy Futures mit Coroutinen in C++20
- Mit Coroutinen einen Future in einem eigenen Thread ausführen
co_yield
Verallgemeinerung des Generators
Verwunderlich ist vielleicht für manche, dass ich das volle generische Potenzial des Generators in meinem letzten Artikel nicht ausgenutzt habe. Im folgenden Beispiel passe ich die Implementierung des Generators an, sodass er die Elemente eines beliebigen Containers der Standard Template Library sukzessiv ausgeben kann:
// coroutineGetElements.cpp
#include <coroutine>
#include <memory>
#include <iostream>
#include <string>
#include <vector>
template<typename T>
struct Generator {
struct promise_type;
using handle_type = std::coroutine_handle<promise_type>;
Generator(handle_type h): coro(h) {}
handle_type coro;
~Generator() {
if ( coro ) coro.destroy();
}
Generator(const Generator&) = delete;
Generator& operator = (const Generator&) = delete;
Generator(Generator&& oth): coro(oth.coro) {
oth.coro = nullptr;
}
Generator& operator = (Generator&& oth) {
coro = oth.coro;
oth.coro = nullptr;
return *this;
}
T getNextValue() {
coro.resume();
return coro.promise().current_value;
}
struct promise_type {
promise_type() {}
~promise_type() {}
std::suspend_always initial_suspend() {
return {};
}
std::suspend_always final_suspend() noexcept {
return {};
}
auto get_return_object() {
return Generator{handle_type::from_promise(*this)};
}
std::suspend_always yield_value(const T value) {
current_value = value;
return {};
}
void return_void() {}
void unhandled_exception() {
std::exit(1);
}
T current_value;
};
};
template <typename Cont>
Generator<typename Cont::value_type> getNext(Cont cont) {
for (auto c: cont) co_yield c;
}
int main() {
std::cout << '\n';
std::string helloWorld = "Hello world";
auto gen = getNext(helloWorld); // (1)
for (int i = 0; i < helloWorld.size(); ++i) {
std::cout << gen.getNextValue() << " "; // (4)
}
std::cout << "\n\n";
auto gen2 = getNext(helloWorld); // (2)
for (int i = 0; i < 5 ; ++i) { // (5)
std::cout << gen2.getNextValue() << " ";
}
std::cout << "\n\n";
std::vector myVec{1, 2, 3, 4 ,5};
auto gen3 = getNext(myVec); // (3)
for (int i = 0; i < myVec.size() ; ++i) { // (6)
std::cout << gen3.getNextValue() << " ";
}
std::cout << '\n';
}
In diesem Beispiel wird der Generator dreimal instanziiert und verwendet. In den ersten zwei Fällen werden gen1
(Zeile 1) und gen2
(Zeile 2) mit std::string helloWorld
initialisiert, während gen3
einen std::vector<int>
(Zeile 3) einsetzt. Die Ausgabe des Programms verhält sich wie erwartet. Zeile 4 gibt alle Buchstaben des Strings helloWorld
sukzessive zurück, hingegen Zeile 5 nur die ersten fünf Buchstaben und Zeile 6 alle Element von std::vector<int>
.
Dank des Compiler Explorer lässt sich das Programm in Aktion bewundern:
Die Implementierung des Generator<T>
ist beinahe identisch zu seiner vorherigen im Artikel "Ein unendlicher Datenstrom dank Coroutinen in C++20". Den entscheidenden Unterschied stellt die Coroutine getNext
dar:
template <typename Cont>
Generator<typename Cont::value_type> getNext(Cont cont) {
for (auto c: cont) co_yield c;
}
getNext
ist ein Funktions-Template, das einen Container als Argument annimmt und anschließend mittels einer Range-basierten for
-Schleife durch all seine Elemente iteriert. Nach jeder Iteration pausiert das Funktions-Template. Der Rückgabetyp Generator<typename Cont::value_type>
mag befremdlich wirken. Cont::value_type
ist ein abhängiger Template-Parameter (dependend template parameter), für den der Parser einen Hinweis benötigt. Per Default nimmt der Compiler an, dass dies ein Nicht-Typ ist, obwohl das Argument auch als ein Typ interpretiert werden kann. Genau aus dem Grund muss ich typename Con::value_type
voranstellen.
Die Arbeitsabläufe
Der Compiler transformiert die Coroutine und führt zwei Arbeitsabläufe aus: den äußeren Promise-Arbeitsablauf und den inneren Awaiter-Arbeitsablauf. Bisher habe ich nur den äußeren Arbeitsablauf vorgestellt, der auf den Methoden des promise_type
basiert:
{
Promise prom;
co_await prom.initial_suspend();
try {
<function body having co_return, co_yield, or co_wait>
}
catch (...) {
prom.unhandled_exception();
}
FinalSuspend:
co_await prom.final_suspend();
}
Der Arbeitsablauf sollte vertraut wirken. Die Komponenten des Arbeitsablaufs sind die Methode prom.initial_suspend()
, der Funktionskörper der Coroutine und die Methode prom.final_suspend()
.
Der innere Arbeitsablauf basiert auf den Awaitables, die Awaiters zurückgeben. Ich habe meine Erklärung absichtlich deutlich vereinfacht. Zwei vordefinierte Awaitables habe ich bereits häufig verwendet:
-
std::suspend_always
struct suspend_always {
constexpr bool await_ready() const noexcept { return false; }
constexpr void await_suspend(std::coroutine_handle<>) const noexcept {}
constexpr void await_resume() const noexcept {}
};
-
std::suspend_never
struct suspend_never {
constexpr bool await_ready() const noexcept { return true; }
constexpr void await_suspend(std::coroutine_handle<>) const noexcept {}
constexpr void await_resume() const noexcept {}
};
Nun ist es offensichtlich, auf welchen Komponenten der Awaiter-Arbeitsablauf basiert, nämlich auf den Methoden await_ready()
, await_suspend()
und await_resume()
des Awaitable.
awaitable.await_ready() returns false:
suspend coroutine
awaitable.await_suspend(coroutineHandle) returns:
void:
awaitable.await_suspend(coroutineHandle);
coroutine keeps suspended
return to caller
bool:
bool result = awaitable.await_suspend(coroutineHandle);
if result:
coroutine keep suspended
return to caller
else:
go to resumptionPoint
another coroutine handle:
auto anotherCoroutineHandle = awaitable.await_suspend(coroutineHandle);
anotherCoroutineHandle.resume();
return to caller
resumptionPoint:
return awaitable.await_resume();
Ich habe den Awaiter-Arbeitsablauf in einer Pseudosprache dargestellt. Das Verständnis dieses Arbeitsablaufs ist das entscheidende Puzzlestück für das Verständnis von Coroutinen.
Wie geht's weiter?
In meinem nächsten Artikel werde ich tiefer in den Awaiter-Arbeitsablauf abtauchen, der auf dem Awaitable basiert. Sei auf ein zweischneidiges Schwert gefasst: Einerseits sind benutzerdefinierte Awaitables sehr mächtige Werkzeuge, andererseits sind sie nicht einfach zu verstehen.
C++ Schulung für Kurzentschlossene
Meine Schulung Embedded-Programmierung mit modernem C++ (12. bis 14. April 2022) findet bereits in zwei Wochen statt. ()