C++20 Ranges library - what are the ranges, how to use them, what mistakes should we avoid and how to apply ranges on custom collections.
The new feature C++20 provided us is partially here and can help us with our primary mission as C++ developers: Maintain the code and protect it from strangers! So let’s protect the world! **code, I meant the code.
What is a Range?
Range is a concept definition (std::ranges::range
) that defines a collection which supports begin
and end
functionality of iterators (as any STL collection).
The range
library brings us the ability to pipe functions which iterate over the rage elements and to evaluate specific algorithms on each one of them and even modify them.
Usage Example
#include <iostream>
#include <vector>
#include <ranges>
int main() {
std::vector<int> ints = {0, 1, 2, 3, 4, 5};
auto even = [](int i) { return i % 2 == 0; };
auto square = [](int i) { return i * i; };
for (int i : ints | std::views::filter(even) | std::views::transform(square)) {
std::cout << i << ' ';
}
return EXIST_SUCCESS;
}
Explanation
std::views::filter
and std::views::transform
are “Range adaptors”. Range adaptors can take a viewable_range
or a view
using the pipe operator as well as a regular parameter.
std::ranges::viewable_range
– A concept which defines a range that can be safely converted into a view
.
std::ranges::view
– A concept which defines a range type that has constant time copy, move, and assignment operations (e.g., a pair of iterators or a generator Range
that creates its elements on-demand. Notably, the standard library containers are ranges, but not views.)
* In order to dive deeper into the requirements, please refer to cppreference – view.
Although this step takes C++ toward other languages standards such as Bash of Linux and AngularJs, it should be treated carefully. Any missing thought when using ranges abilities might bring our worst enemies into taking down our code performances.
Consider the following case:
#include <iostream>
#include <vector>
#include <ranges>
int main() {
std::vector<int> ints = {1, 3, 5, 7, 9, 11};
auto even = [](auto elem) { return elem % 2 == 0; };
auto inc = [](auto elem) { return ++elem; };
auto range = ints | std::views::transform(inc) | std::views::filter(even);
for (int i : range) { std::cout << i << ' '; }
return EXIST_SUCCESS;
}
On line 11, we can see that the total number of operations that we performed is 2n
, and on this example, this is the optimal solution. But let’s consider the following case:
std::vector<int> ints = {1, 3, 5, 7, 9, 11};
auto odd = [](auto elem) { return elem % 2; };
auto inc = [](auto elem) { return ++elem; };
auto range = ints | std::views::transform(inc) | std::views::filter(odd);
for (int i : range) { std::cout << i << ' '; }
Here, we got again 2n
operations, although all we really needed was n
operations, and to archive that, all we needed to do is to use the opposite filter before the transformation:
auto range = ints | std::views::filter(even) | std::views::transform(inc);
Another option to call the opposite function:
auto notf = [](auto &func) {
return [&](auto elem) {
return !func(elem);
};
};
auto range = ints | std::views::filter(notf(odd)) | std::views::transform(inc);
Parentheses & Adaptors
There is no meaning to parentheses between adaptors calls. Unlike mathematical expression, here on functions call, we won’t be able to change the calling order based on parentheses. For example:
auto inc = [](auto elem) { return ++elem; };
auto square = [](auto elem) { return elem * elem; };
auto div = [](auto elem) { return elem / 2; };
auto range = ints | std::views::transform(inc) |
std::views::transform(square) | std::views::transform(div);
for (int i : range) { std::cout << i << ' '; }
Additional Views
std::views::take(range, n)
– Take the first n
elements from a range.
auto range = collection | std::views::take(3);
std::views::take_while(range, pred)
– Take all the elements from the beginning of the range, until the pred returns false
.
std::vector<int> ints = {1, 3, 5, 7, 2, 4, 9, 11};
auto range = ints | std::views::take_while([](auto elem)
{ return elem < 7; });
std::views::drop(range, n)
– Drop the first n
elements from the range. std::views::drop_while(range, pred)
– Drop the first elements until pred
returns false
. std::views::split(range, pattern)
– Split the range into several ranges whenever pattern
exists on the range.
std::string str = "Hello,World,of,C++,ranges";
auto ranges = str | std::views::split(',');
for (auto range : ranges) {
for (auto elem : range) {
std::cout << elem;
}
std::cout << '\n';
}
Result
Hello
World
of
C++
ranges
std::views::join(ranges)
– Join split ranges:
std::string str = "Hello,World,of,C++,ranges";
auto ranges = str | std::views::split(',');
auto join_ranges = ranges | std::views::join;
for (auto elem : join_ranges) {
std::cout << elem << " ";
}
std::views::reverse(range)
– Reverse elements order in range std::views::keys(key_value_range)
– Takes all the keys from a key-value consisting view std::views::values(key_value_range)
– Takes all the values from a key-value consisting view:
std::map<std::string, int> my_map;
my_map.insert({"key1", 2});
my_map.insert({"key2", 4});
my_map.insert({"key3", 5});
my_map.insert({"key4", 6});
my_map.insert({"key5", 8});
std::cout << "Keys: ";
for (auto key : my_map | std::views::keys) {
std::cout << key << " ";
}
std::cout << "\nValues: ";
for (auto value : my_map | std::views::values) {
std::cout << value << " ";
}
For more available view, see cppreference – ranges.
Custom Ranges Collection
For this section, I’ll use the custom collection I described in a previous article: Maintain Your Iterations – Iterators Customization – Part 3.
Assume we have a custom made collection, which supports iterators:
#include <iostream>
#include <vector>
#include <ranges>
#include "custom_iterator.h" // Taken from a previous article:
int main() {
my_item_collection<std::vector, int> custom_ints2;
custom_ints2.add_item(5, 3);
custom_ints2.add_item(2, 7);
custom_ints2.add_item(1, 4);
return EXIT_SUCCESS;
}
We can now iterate over our custom collection using ranges adaptors:
int main() {
auto my_item_even = [] <typename T>
(my_item<T> &elem){ return elem.item() % 2 == 0; };
auto my_item_square = [] <typename T> (my_item<T> &elem)
{return elem.item() * elem.item(); };
for (auto elem : custom_ints2 | std::views::filter(my_item_even) |
std::views::transform(my_item_square)) {
std::cout << elem << ' ';
} std::cout << '\n';
}
However, here we created custom access methods just to implement methodology that already exists in our code:
auto even = [](auto elem) { return elem % 2 == 0; };
auto square = [](auto elem) { return elem * elem; };
To avoid creation of custom methods that implement an existing methodology, we can use a single custom conversion method:
auto my_item_to_int = [] <typename T> (my_item<T> &elem){ return elem.item(); };
for (auto elem : custom_ints2 | std::views::transform(my_item_to_int) |
std::views::filter(even) | std::views::transform(square)) {
std::cout << elem << ' ';
}
std::cout << '\n';
Summary
C++ 20 Ranges is a feature that makes our code easier to maintain in the long term and can significantly reduce the loops amount on our programs.
Full examples repository: cppsenioreas-ranges