C++23: Ranges Verbesserungen und std::generator
C++23 führt eine konkrete Koroutine ein, nachdem C++lediglich einen Rahmen für deren Implementierung geboten hat.
- Rainer Grimm
C++20 bietet keine konkreten Koroutinen, aber einen Rahmen, um sie zu implementieren. Das ändert sich mit C++23. std::generator
ist die erste konkrete Koroutine.
std::generator
ist Bestandteil der Erweiterung der Ranges-Bibliothek in C++23. Daher möchte ich diesen Artikel mit der Ranges-Bibliothek in C++20 und ihrer Erweiterung in C++23 beginnen. Ich werde mich aber kurzfassen. Ich habe bereits über die Ranges-Bibliothek in C++20 und ihre Erweiterung in C++23 geschrieben:
- Meine Artikel zu Ranges
- Ranges: Verbesserungen mit C++23
Es gibt nur eine Geschichte, die ich abschließen möchte:
Pythons range
Funktion in C++23
In meinen Artikeln "Pythonisch mit der ranges Bibliothek: range und filter" und "Pythons range Funktion, die zweite" habe ich die range-
Funktion in Python 2 mit der Ranges-Bibliothek implementiert. Der Unterschied zwischen der Python-2- und der Python-3- range-
Funktion ist, dass die 2er-Version eagert, die 3er-Version aber lazy ist. Das bedeutet, dass die 2er-Version die Zahlen erzeugt, während die 3er-Version einen Generator zurückgibt, der die Zahlen auf Anfrage erzeugt. Ich konnte das Problem in C++20 nur lösen, indem ich die range-v3-Bibliothek von Eric Niebler verwendete. Insbesondere benötigte ich die Funktion stride(N)
, die über einen anderen View iteriert und jedes Mal das N
-Element zurückgibt.
std::ranges::views::stride
ist Teil von C++23, und die GCC- und MSVC-Compiler unterstützen sie. Außerdem verwende ich in diesem Beispiel die praktische Funktion std::ranges::to.
Mit dieser C++23-Funktion lassen sich Container und Strings aus Bereichen konstruieren. Diese Funktion wird bisher nur von den Compilern MSVC und Clang unterstützt.
Hier ist nun Pythons range-
Funktion in C++:
// rangeCpp23.cpp
#include <iostream>
#include <ranges>
#include <vector>
std::vector<int> range(int begin, int end, int stepsize = 1) {
std::vector<int> result{};
if (begin < end) { // (5)
auto boundary = [end](int i){ return i < end; };
result = std::ranges::views::iota(begin)
| std::views::stride(stepsize)
| std::views::take_while(boundary)
| std::ranges::to<std::vector>();
}
else { // (6)
begin++;
end++;
stepsize *= -1;
auto boundary = [begin](int i){ return i < begin; };
result = std::ranges::views::iota(end)
| std::views::take_while(boundary)
| std::views::reverse
| std::views::stride(stepsize)
| std::ranges::to<std::vector>();
}
return result;
}
int main() {
std::cout << std::endl;
// range(1, 50) // (1)
auto res = range(1, 50);
for (auto i: res) std::cout << i << " ";
std::cout << "\n\n";
// range(1, 50, 5) // (2)
res = range(1, 50, 5);
for (auto i: res) std::cout << i << " ";
std::cout << "\n\n";
// range(50, 10, -1) // (3)
res = range(50, 10, -1);
for (auto i: res) std::cout << i << " ";
std::cout << "\n\n";
// range(50, 10, -5) // (4)
res = range(50, 10, -5);
for (auto i: res) std::cout << i << " ";
std::cout << "\n\n";
}
Die Aufrufe in den (1) - (4) sollten ziemlich einfach zu lesen sein, wenn man die Ausgabe des MSVC-Compilers betrachtet:
Die ersten beiden Argumente des range-Aufrufs stehen für den Anfang und das Ende der erzeugten Zahlen. Der Anfang wird einbezogen, das Ende jedoch nicht. Die Schrittweite als dritter Parameter ist standardmäßig 1. Wenn das Intervall [begin, end]
absteigt, sollte die Schrittweite negativ sein. Ansonsten erhält man eine leere Liste oder einen leeren std::vector<int>
.
Die if-Bedingung (begin < end
) der ranges-
Funktion in (1) sollte recht einfach zu lesen sein: Erstelle alle Zahlen, die mit begin
beginnen (std::views::iota(begin)
), nehme jedes n-te Element (std::views::stride(stepsize)
) und mache das so lange, wie die Randbedingung gilt (std::views::take_while(boundary)
). Schließlich erstelle ich den std::vector<int>
. Im else
-Fall (2) verwende ich einen kleinen Trick: Ich erstelle die Zahlen [end++, begin++[
, nehme sie, bis die Randbedingung erfüllt ist, drehe sie um (std::views::reverse
) und nehme jedes n-te Element.
Nun möchte ich mich den Koroutinen widmen.
std::generator
std::generator
in C++23 ist die erste konkrete Koroutine. Ein std::generator
erzeugt eine Folge von Elementen, in dem die Koroutine dort ihren Kontrollfluss wieder aufnimmt, wo sie pausiert hat.
// generator.cpp
#include <generator>
#include <ranges>
#include <iostream>
std::generator<int> fib() {
co_yield 0; // (1)
auto a = 0;
auto b = 1;
for(auto n : std::views::iota(0)) {
auto next = a + b;
a = b;
b = next;
co_yield next; // (2)
}
}
int main() {
for (auto f : fib() | std::views::take(10)) {
std::cout << f << " ";
}
}
Die Funktion fib
ist eine Koroutine. Diese Koroutine erzeugt einen unendlichen Strom von Fibonacci-Zahlen. Der Zahlenstrom beginnt mit 0 (1) und wird mit der nächsten Fibonacci-Zahl fortgesetzt (2). Die Range-based for-Schleife fordert explizit die ersten 10 Fibonacci-Zahlen an.
Bislang unterstützt kein Compiler std::generator
. Es lässt sich die Koroutinen-Natur von std::generator
schön an seinem Header studieren:
std::ranges::elements_of
kommt ins Spiel, wenn ein Generator rekursiv aufgerufen werden soll.
std::generator<int> fib() {
co_yield 0;
auto a = 0;
auto b = 1;
for(auto n : std::views::iota(0)) {
auto next = a + b;
a = b;
b = next;
co_yield next;
}
}
std::generator<int> outer() {
yield fib(); // (1)
yield std::ranges::elements_of(fib); // (2)
}
Der äußere Generator gibt in Zeile (1) den inneren std::generator<int>
zurück, aber in Zeile (2) die Werte des inneren Generators. Beide Koroutinen besitzen den gleichen Rückgabetyp.
std::bind_back
Entsprechend zu std::bind_front
in C++20 unterstützt C++23 std::bind_back
. Das folgende Programm bindFrontBack.cpp
zeigt die Anwendung der beiden Funktionen.
// bindFrontBack.cpp
#include <functional>
#include <iostream>
#include <string>
int main() {
std::cout << '\n';
auto add = [](std::string a, std::string b, std::string c) {
return a + b + c;
};
auto two_three = std::bind_front(add, "one ");
std::cout << two_three("two ", "three ") << '\n';
auto one_two = std::bind_back(add, "three ");
std::cout << one_two("one ", "two ") << '\n';
std::cout << '\n';
}
Dank der Ausgabe sollte sich Programm selbst erklären.
Mehr zu Partial Function Applications mit std::bind, std::bind_front
und std::bind_back
findet sich in meinen Artikel "Techniken in der Softwareentwicklung: Partial Function Application".
Wie geht es weiter?
Jetzt bin ich fertig mit C++23. Daher möchte ich sechs Jahre zurückspringen. In meinem nächsten Artikel werde ich über ein fast unbekanntes Feature in C++17 schreiben: polymorphe Allokatoren. (rme)