Part of the C++ series:
- C++ Compilers
- Microbenchmarking in C++
- Automatic Type Deduction
- Algorithms in the C++ Standard Library
- Ranges in C++This post!
- Constructors and Move Semantics in C++
- Compile-Time Programming in C++
- Building Neural Networks in C++
- Concurrency in C++
- Building a Thread Pool in C++
- Value Semantics in C++
- Building a Log Utility Class in C++
In my previous post we discussed many of the benefits of using the C++ Algorithms library as well as some of the downsides. The biggest issue being that we cannot efficientyl compose multiple algorithms together with expensive copies of the underlying containers. The introduction of the Ranges library in C++ solves this issue while providing a more modern syntax that will make your code easier to comprehend and reason about.
Views
Views in the Ranges library are leazy evaluated iterators over a range. Consider this example which squares all values in a vector
C++1 vector<int> numbers = std::vector{1,2,3,4};
2 auto square = [](int v) { return v * v; };
3 auto squared_view = std::views::transform(numbers, square);
4 for (int s : squared_view) {
5 std::cout << s << " ";
6 }
7 // Output: 1 2 9 16
In contrast to the Algorithms library, squared_view is not a copy of the numbers vector with the values squared. Instead it is a proxy object for numbers that is lazily evaluated. That means only when we access an element in squared_view does the std::transform() function get invoked. Only when our iterator comes across the value at index 2 does the square lambda get invoked to produce 9. This allows us to treat squared_view as a container with we can run function like find() and count() on, but internally no new container with a copy of data was created. If you need to store the squared_view as its own container using std::ranges::copy().
Composability
The nature of views and its ability to not copy containers means we now have the power to efficiently combine multiple operations. There problem we faced post involving finding the max score for students in their second year can be solved with the Ranges library like so
C++ 1 int get_max_score(const std::vector<Student>& s, int year) {
2 auto by_year = [=](const auto&s) { return s.year_ == year; };
3
4 auto v1 = std::ranges::ref_view{s}; // Wrap our vector container in a view
5 auto v2 = std::ranges::filter_view{v1, by_year};
6 auto v3 = std::ranges::transform_view{v2, &Student::score_};
7
8 auto it = std::ranges::max_element(v3);
9 return it != v3.end() ? *it : 0;
10 }
Here we chain all the different algorithms we need without once creating a copy of data. V1, v2, and v3 are all wrappers around our original Student vector that get lazily evaluated when needed.
Range Adaptors
The composability above is great, but the Ranges library also comes with the option to use a less verbose syntax that I could argue is more “readable”. Ranges allows us to compose our views using range adaptors and pipe operators to achieve this syntax change. If we wished, we would instead write the exampe given above as
C++1 int get_max_score(const std::vector<Student>& s, int year) {
2 auto by_year = [=](const auto&s) { return s.year_ == year; };
3
4 using namespace std::views; // range adaptors live in std::views
5 auto scores = s | filter(by_year) | transform(&Student::score_);
6
7 auto it = std::ranges::max_element(scores);
8 return it != v3.end() ? *it : 0;
9 }
The scores view replaces the v1, v2, and v3 above making our code much less verbose. It also is processed left to right meaning we first transform to a view, filter by year, and transform based on the score_. Reading left to write makes our code more human readable which is often a good thing.
View Ownership Model
A few things to note about these views. They are technically non-owning ranges with comlexity guarantees. A range is any type that provides a begin() to return a starting iterator and an end() to return a sentinel. All standard containers are ranges, most of which own their own elements. While views are ranges that provide begin() and end(), they do not own the elements. This allows views to be composable, assigned, copied, moved, and destructed in O(1) constant time. But it also means the views do not mutate the underlying container they are wrapping. But what if you need to materialize your view into a new owning container? As mentioned above, you would use std::ranges::copy()
C++1 auto ints = std::list{2,3,4,2,1};
2 using namespace std::views;
3 auto r = ints | transform([](auto i) { return std::to_string(i);});
4
5 auto vec = std::vector<std::string>{};
6 copy(r, std::back_inserter(vec));
Examples
Being able to generate a view with unique properties in constant time O(1)
C++1for (auto i : std::views::iota(-2,2)) {
2 std::cout<< i << " ";
3}
4// Output: -2 -1 0 1
Extracting all digits from a string
C++1auto csv = std::string{"10,11,12"};
2auto digits = csv | std::views::split(',') | std::views::join;
3for (auto i : digits) {
4 std::cout<< i << " ";
5}
6// Output: 101112
Sampling three values from a container that match certain qualifications
C++1auto vec = std::vector{1,2,3,4,5,4,3,2,1};
2auto v = vec | std::views::drop_while([](auto i) { i < 5;}) | std::views::take(3);
3for (auto i : v) {
4 std::cout<< i << " ";
5}
6// Output: 5 4 3