Die Ranges-Bibliothek in C++20: Weitere Designentscheidungen​

Die Ranges-Bibliothek in C++20 hat aus Performancegründen ein paar besondere Designentscheidungen zum Caching und zur Konstanz getroffen.

In Pocket speichern vorlesen Druckansicht 45 Kommentare lesen
Neural,Network,3d,Illustration.,Big,Data,And,Cybersecurity.,Data,Stream.

(Bild: Yurchanka Siarhei / Shutterstock.com)

Lesezeit: 3 Min.
Von
  • Rainer Grimm
Inhaltsverzeichnis

Im Interesse der Performance gibt es einige Besonderheiten der Ranges-Bibliothek in C++20. Diese Designentscheidungen besitzen Konsequenzen: Probleme mit dem Cache und der Konstanz.

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

Hier ist eine kurze Erinnerung. In meinem letzten Artikel, "Die Ranges-Bibliothek in C++20: Designentscheidungen", habe ich diese mögliche Implementierung von std::ranges::filter_view vorgestellt:

if constexpr (!ranges::forward_range<V>)
    return /* iterator */{*this, ranges::find_if(base_, std::ref(*pred_))};
else
{
    if (!begin_.has_value())
        begin_ = ranges::find_if(base_, std::ref(*pred_)); // caching
    return /* iterator */{*this, begin_.value())};
}

Die wichtigste Beobachtung ist, dass der begin-Iterator für nachfolgende Aufrufe zwischengespeichert wird. Diese Zwischenspeicherung hat zwei interessante Konsequenzen:

  • Man darf eine View nicht in geänderten Bereichen verwenden und
  • man darf keine View kopieren.

Oder positiv formuliert: Man muss Views direkt verwenden, nachdem man sie definiert hat.

Es gibt noch mehr wichtige Designentscheidungen.

Die Mitgliedsfunktion einer View kann die Position zwischenspeichern. Das impliziert die folgenden Punkte:

  • Eine Funktion, die eine beliebige View annimmt, sollte diesen per Universal Referenz annehmen und
  • das gleichzeitige Lesen zweier Views kann zu einem Data Race führen.

Zuerst möchte ich mich dem ersten Punkt widmen.

Die Funktion printElements nimmt ihren View per Universal Referenz an.

void printElements(std::ranges::input_range auto&& rang) {
    for (int i: rang) {
        std::cout << i << " ";  
    }
    std::cout << '\n';
}

printElements nimmt ihr Argument per Universal Referenz an. Sie per lvalue-Referenz oder als Wert anzunehmen, besitzt negative Konsequenzen.

Das Argument per konstanter lvalue-Referenz anzunehmen schlägt fehl, weil der implizite begin-Aufruf der View das Argument verändern kann. Im Gegenteil dazu kann eine nichtkonstante lvalue-Referenz keinen rvalue verarbeiten.

Das Argument als Wert anzunehmen, kann hingegen den Zwischenspeicher ungültig machen.

Das folgende Programm veranschaulicht das Problem der Concurrency bei Views:

// dataRaceRanges.cpp

#include <numeric>
#include <iostream>
#include <ranges>
#include <thread>
#include <vector>
 
int main() {

    std::vector<int> vec(1'000);
    std::iota(vec.begin(), vec.end(), 0);

    auto first5Vector = vec | std::views::filter([](auto v) { return v > 0; }) 
                            | std::views::take(5);

    std::jthread thr1([&first5Vector]{
        for (int i: first5Vector) {
            std::cout << i << " ";  
        }
    });


    for (int i: first5Vector) {
        std::cout << i << " ";  
    }

    std::cout << "\n\n";
    
}

Ich iteriere im Programm dataRaceRanges.cpp zweimal gleichzeitig durch eine View, ohne ihn zu verändern. Zuerst iteriere ich im std::jthread thr1 und dann in der main-Funktion. Das ist ein Data Race, weil beide Iterationen implizit die Mitgliedsfunktion begin verwenden, die die Position zwischenspeichern kann. ThreadSanitizer macht dieses Data Race sichtbar und beschwert sich über ein vorheriges Schreiben in Zeile 24: std::cout << i << " ";

Im Gegensatz dazu ist die Iteration durch einen klassischen Container wie std::vector thread-safe. Es gibt einen weiteren Unterschied zwischen klassischen Containern und Views.

Klassische Container modellieren tiefe Konstanz. Sie geben sie an ihre Elemente weiter. Das bedeutet, dass es unmöglich ist, Elemente eines konstanten Containers zu ändern.

// constPropagationContainer.cpp

#include <iostream>
#include <vector>

template <typename T>
void modifyConstRange(const T& cont) {
    cont[0] = 5;
}
 
int main() {

    std::vector myVec{1, 2, 3, 4, 5};
    modifyConstRange(myVec);         // ERROR

} 

Der Aufruf modifyConstRange(myVec)verursacht einen Fehler zur Compile-Zeit.

Im Gegensatz dazu modellieren Views eine flache Konstanz. Sie geben sie nicht an ihre Elemente weiter. Die Elemente können daher trotzdem geändert werden.

// constPropagationViews.cpp

#include <iostream>
#include <ranges>
#include <vector>

template <typename T>
void modifyConstRange(const T& cont) {
    cont[0] = 5;
}
 
int main() {

    std::vector myVec{1, 2, 3, 4, 5};

    modifyConstRange(std::views::all(myVec));   // OK

}  

Der Aufruf modifyConstRange(std::views::all(myVec)) ist in Ordnung.

Coroutinen sind wahrscheinlich die anspruchsvollste Komponente von C++20. Mein nächster Artikel ist ein Gastbeitrag von Dian-Lun Lin. Er wird eine kurze Einführung in Coroutinen geben und seine Idee anhand eines einfachen Schedulers, der Tasks verwaltet, veranschaulichen. (rme)