Ein generischer Datenstrom mit Coroutinen in C++20

Ein generischer Datenstrom mit Coroutinen in C++20

In meinem letzten Beitrag in dieser Mini-Serie zu Coroutinen aus der praktischen Perspektive habe ich den Workflow von „An Infinite Data Stream with Coroutines in C++20“ vorgestellt. In diesem Beitrag nutze ich das generische Potenzial des Datenstroms.

Dieser Beitrag setzt voraus, dass Sie den vorherigen Beitrag „An Infinite Data Stream with Coroutines in C++20“ kennen, in dem ich anhand des neuen Schlüsselworts co_yield sehr ausführlich den Workflow eines unendlichen Generators erkläre Bisher habe ich über die neuen Schlüsselwörter co_return geschrieben , und co_yield, was aus einer Funktion eine Coroutine macht. Im nächsten Post schaue ich mir das herausforderndste neue Keyword co_await genauer an .

co_return :

  • Einfache Futures mit Coroutinen implementieren
  • Lazy Futures mit Coroutinen in C++20
  • Ein Future in einem separaten Thread mit Coroutinen ausführen

co_yield:

  • Ein unendlicher Datenstrom mit Coroutinen in C++20

Endlich mal was Neues.

Verallgemeinerung des Generators

Sie fragen sich vielleicht, warum ich in meinem letzten Beitrag nie das volle generische Potenzial von Generator genutzt habe. Lassen Sie mich seine Implementierung anpassen, um die aufeinanderfolgenden Elemente eines beliebigen Containers der Standard-Vorlagenbibliothek zu erzeugen.

// 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 beiden Fällen gen (Zeile 1) und gen2 (Zeile 2) werden mit std::string helloWorld initialisiert , während gen3 verwendet einen std::vector<int> (Zeile 3). Die Ausgabe des Programms sollte nicht überraschen. Zeile 4 gibt alle Zeichen des Strings helloWorld zurück Zeile 5 nacheinander nur die ersten fünf Zeichen und Zeile 6 die Elemente der std::vector<int> .

Sie können das Programm im Compiler Explorer ausprobieren. Um es kurz zu machen. Die Implementierung von Generator<T> ist fast identisch mit dem vorherigen im Beitrag An Infinite Data Stream with Coroutines in C++20. Der entscheidende Unterschied zum vorherigen Programm ist die Coroutine getNext .
template <typename Cont>
Generator<typename Cont::value_type> getNext(Cont cont) {
 for (auto c: cont) co_yield c;
}

getNext ist eine Funktionsvorlage, die einen Container als Argument verwendet und in einer bereichsbasierten for-Schleife durch alle Elemente des Containers iteriert. Nach jeder Iteration pausiert die Funktionsvorlage. Der Rückgabetyp Generator<typename Cont::value_type> mag für Sie überraschend erscheinen. Cont::value_type ist ein abhängiger Template-Parameter, für den der Parser einen Hinweis benötigt. Standardmäßig geht der Compiler von einem Nicht-Typ aus, wenn er als Typ oder Nicht-Typ interpretiert werden könnte. Aus diesem Grund muss ich typename eingeben vor Cont::value_type.

Die Arbeitsabläufe

Der Compiler transformiert Ihre Coroutine und führt zwei Workflows aus:den äußeren Promise-Workflow und der innere Warten-Workflow .

Der Promise-Workflow

Bisher habe ich nur über den äußeren Workflow geschrieben, der auf den Member-Funktionen 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();
}

Wenn Sie meinem vorherigen Beitrag gefolgt sind, sollte Ihnen dieser Workflow bekannt vorkommen. Sie kennen bereits die Komponenten dieses Workflows wie prom.initial_suspend() , der Funktionsrumpf und prom.final_suspend().

Der Awaiter-Workflow

Der äußere Workflow basiert auf den Awaitables, die Awaiter zurückgeben. Ich habe diese Erklärung absichtlich vereinfacht. Sie kennen bereits zwei vordefinierte Awaitables:

  • 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 {}
};

Nein, Sie ahnen vielleicht schon, auf welchen Teilen der Waiter-Workflow basiert? Recht! Auf den Mitgliedsfunktionen await_ready() , await_suspend() , und await_resume() des Erwarteten.

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 Waiter-Workflow in einer Pseudosprache dargestellt. Das Verständnis des Awaiter-Workflows ist das letzte Puzzleteil, um eine Intuition über das Verhalten von Coroutinen zu haben und wie Sie sie anpassen können.

Was kommt als nächstes?

In meinem nächsten Beitrag gehe ich näher auf den Awaiter-Workflow ein, der auf Awaitable basiert. Seien Sie auf das zweischneidige Schwert vorbereitet. Benutzerdefinierte Awaitables geben Ihnen große Möglichkeiten, sind aber schwierig zu verstehen.