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.
- Rainer Grimm
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.
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.
Konstanz
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.
Beliebige Views mittels Universal Referenz nehmen
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.
Gleichzeitiges Lesen von Views kann ein Data Race sein
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.
Propagation der Konstanz
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.
Wie geht's weiter?
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)