Programmiersprache C++: Komposition von Sendern mit std::execution

Die meisten Sender-Adapter können mithilfe des Pipe-Operators zusammengesetzt werden.

vorlesen Druckansicht 2 Kommentare lesen
BuchstabenwĂĽrfel mit C und Plus

(Bild: SerbioVas/Shutterstock)

Lesezeit: 2 Min.
Von
  • Rainer Grimm
Inhaltsverzeichnis

Nach meinen Erläuterungen zum asynchronen Ausführen von Inclusive Scan mit std::execution widme ich mich nun der Komposition von Sendern.

Modernes C++ – Rainer Grimm
Rainer Grimm

Rainer Grimm ist seit vielen Jahren als Softwarearchitekt, Team- und Schulungsleiter tätig. Er schreibt gerne Artikel zu den Programmiersprachen C++, Python und Haskell, spricht aber auch gerne und häufig auf Fachkonferenzen. Auf seinem Blog Modernes C++ beschäftigt er sich intensiv mit seiner Leidenschaft C++.

Lassen Sie mich mit einem einfachen Beispiel fĂĽr die Komposition mit dem Pipe-Operator beginnen. Anstelle der verschachtelten Funktionsaufrufe

call1(call2(input))

lässt sich alternativ schreiben

call1 | call2(input)

oder sogar:

input | call1 | call2

Dieses Beispiel ist zugegebenermaßen sehr einfach. Machen wir es etwas komplizierter. Der Proposal P2300R10 vergleicht den Aufruf einer verschachtelten Funktion mit dem Aufruf einer Funktion unter Verwendung eines temporären Objekts und einer Komposition unter Verwendung des Pipe-Operators.

Alle drei Funktionskompositionen berechnen 610 = (123*5)-5 unter Verwendung eines Thread-Pools und CUDA. Man beachte in den folgenden Codebeispielen insbesondere das Lambda []{ return 123; }). Dieses Lambda wird nicht ausgewertet.

auto snd = execution::then(
             execution::continues_on(
               execution::then(
                 execution::continues_on(
                   execution::then(
                     execution::schedule(thread_pool.scheduler())
                     []{ return 123; }),
                   cuda::new_stream_scheduler()),
                 [](int i){ return 123 * 5; }),
               thread_pool.scheduler()),
             [](int i){ return i - 5; });
auto [result] = this_thread::sync_wait(snd).value();
// result == 610

Es ist nicht einfach, diese Verschachtelung von Funktionsaufrufen zu verstehen und zu erkennen, welche Funktionskörper zusammengehören, oder zu verstehen, warum das Lambda nicht ausgeführt wird. Es macht auch keinen Spaß, diese Komposition zu debuggen oder zu ändern.

auto snd0 = execution::schedule(thread_pool.scheduler());
auto snd1 = execution::then(snd0, []{ return 123; });
auto snd2 = execution::continues_on(snd1, cuda::new_stream_scheduler());
auto snd3 = execution::then(snd2, [](int i){ return 123 * 5; })
auto snd4 = execution::continues_on(snd3, thread_pool.scheduler())
auto snd5 = execution::then(snd4, [](int i){ return i - 5; });
auto [result] = *this_thread::sync_wait(snd4);
// result == 610

Die Verwendung von temporären Variablen kann sehr hilfreich sein, um die Struktur der Komposition zu verstehen. Nun ist die Abfolge der Funktionsaufrufe leicht zu erkennen. Es wird auch ersichtlich, warum die Lambda-Funktion []{ return 123; } nicht ausgeführt wird. Es gibt keinen Sender-Consumer wie this_thread::sync_wait(snd4). Die Sender-Adapter wie then und continue_on sind "lazy". Sie erzeugen nur dann einen Wert, wenn sie dazu aufgefordert werden.

Aus Sicht der Lesbarkeit gefällt mir diese Lösung, aber sie hat einen gravierenden Nachteil: Es werden viele temporäre Objekte erstellt.

auto snd = execution::schedule(thread_pool.scheduler())
         | execution::then([]{ return 123; })
         | execution::continues_on(cuda::new_stream_scheduler())
         | execution::then([](int i){ return 123 * 5; })
         | execution:: Double quote continues_on(thread_pool.scheduler())
         | execution::then([](int i){ return i - 5; });
auto [result] = this_thread::sync_wait(snd).value();
// result == 610

Die Funktionskomposition mit dem Pipe-Operator löst beide Probleme. Erstens ist sie lesbar und zweitens werden keine unnötigen temporären Variablen benötigt.

Die folgenden Sender-Adapter sind nicht pipe-fähig.

  • execution::when_all und execution::when_all_with_variant: Beide Sender-Adapter nehmen eine beliebige Anzahl von Sendern auf. Es wäre daher unklar, welcher Sender angepasst werden sollte.
  • execution::starts_on: Dieser Sender-Adapter ändert die AusfĂĽhrungsressource, auf der der Sender ausgefĂĽhrt wird. Er passt den Sender nicht an.

FĂĽr die Funktionskomposition ist es wichtig, das Layout lesbar zu gestalten. Mache also keinen "schlauen" Einzeiler daraus:

auto snd = execution::schedule(thread_pool.scheduler()) | execution::then([]{ return 123; }) | execution::continues_on(cuda::new_stream_scheduler()) | execution::then([](int i){ return 123 * 5; }) | execution::continues_on(thread_pool.scheduler()) | execution::then([](int i){ return i - 5; }); 

std::execution bietet einige Sender-Fabriken an, insbesondere viele Sender, um den Arbeitsablauf zu modellieren. Ich werde sie in meinem nächsten Beitrag vorstellen. (map)