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.

In Pocket speichern vorlesen Druckansicht 26 Kommentare lesen
Gleisanlagen in Maschen im Gegenlicht

(Bild: MediaPortal der Deutschen Bahn)

Lesezeit: 5 Min.
Von
  • Rainer Grimm
Inhaltsverzeichnis

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.

Modernes C++ – 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++.

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:

Es gibt nur eine Geschichte, die ich abschließen möchte:

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

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

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)