Webinar: C++ semantics - 06.11
C++20 Ranges, also known as STL v2, effectively replaces existing STL algorithms and facilities. In this article, I will guide you through the changes that Ranges introduce, talk about Views, which are a new composable approach to algorithms and show examples of FizzBuzz using three different methods, all utilizing some aspects of Ranges.
We published and translated this article with the copyright holder's permission. The author is Šimon Tóth. The article was originally published on ITNEXT.
Note, however, that Ranges are one of the features that landed in C++20 in a half-baked state. C++23 should bring us much closer to comprehensive support. Some of the examples will therefore use the range v3 library.
As already mentioned, ranges are a drop-in replacement for STL. However, they introduce both internal and user-facing changes that overall improve their usefulness.
Ranges rely on concepts to specify what types of parameters can participate in each overload. Therefore, making a mistake when using ranges will lead to shorter and more to the point error messages.
A typical example is trying to sort a std::list. Unfortunately, this is an easy mistake to make if you are new to C++.
#include <iostream>
#include <ranges>
#include <list>
#include <algorithm>
int main() {
std::list<int> dt = {1, 4, 2, 3};
std::ranges::sort(dt.begin(), dt.end());
std::ranges::copy(dt.begin(), dt.end(),
std::ostream_iterator<int>(std::cout, ","));
}
Instead of receiving a confusing error about the minus operator, we now get the exact problem as the first error:
include/c++/12.0.0/bits/ranges_algo.h:1810:14: note: because
'std::_List_iterator<int>' does not satisfy 'random_access_iterator'
We can inspect the concepts defined by the Ranges library, as these are part of the standard. For example, the concept of a range is very straightforward, and it simply mandates that the expressions std::ranges::begin(rng) and std::ranges::end(rng) are valid. If you want to read up on concepts, check out my concepts guide.
The fundamental change here is that end() no longer needs to return the same type as begin(). The returned sentinel only needs to be comparable to the iterator type returned by begin().
Apart from simplifying certain use cases, it also allows for infinite ranges and potential performance improvement.
std::vector<int> dt = { 1, 2, 3, 4, 5, 6, 7, 8, 9};
std::ranges::shuffle(dt, std::mt19937(std::random_device()()));
auto pos = std::ranges::find(dt.begin(),
std::unreachable_sentinel,
7);
std::ranges::copy(dt.begin(), ++pos,
std::ostream_iterator<int>(std::cout, ","));
The std::unreachable_sentinel always returns false when compared to an iterator. The compiler will therefore optimize out the boundary check it != end since this expression is then always true.
We can only use this trick when we have a contextual guarantee that the algorithm will terminate without going out of bounds, but it brings algorithms on par with hand-written code.
And finally, with the introduction of the range concept, we can also save up on writing and use the range accepting variants of algorithms.
std::vector<int> dt = {1, 4, 2, 3};
std::ranges::sort(dt);
A massive new feature that, on the surface, seems trivial is the support for projections. A projection is a unary invocable that is applied to every element.
This often completely removes the need to write complex lambdas, and when it doesn't, it simplifies them significantly. An invocable is an extension of callable and also accepts member pointers.
struct Account {
std::string owner;
double value();
double base();
};
std::vector<Account> acc = get_accounts();
// member
std::ranges::sort(acc,{},&Account::owner);
// member function
std::ranges::sort(acc,{},&Account::value);
// lambda
std::ranges::sort(acc,{},[](const auto& a) {
return a.value()+a.base();
});
Without projections, we would have to include this logic as part of a custom comparator.
std::vector<int> dt = { 1, 2, 3, 4, 5, 6, 7, 8, 9};
std::vector<int> result;
std::ranges::transform(dt,
dt | std::views::reverse,
std::back_inserter(result),
std::minus<void>(),
[](int v) { return v*v; },
[](int v) { return v*v; });
std::ranges::copy(result,
std::ostream_iterator<int>(std::cout, ","));
This is a slight foreshadowing for views, but I wanted to include another example that utilized two ranges as input. In such a case, we get two separate projections. Note that these projections can also return different return types, as long as they match up with the operation (here std::minus).
One last "small" feature I wanted to mention here is the prevention of dangling iterators. Mainly because even if you don't care for it, you might find use-cases for this particular pattern in your codebase.
auto good = "1234567890";
auto sep1 = std::ranges::find(std::string_view(good), '0');
std::cout << *sep1 << "\n";
auto bad = 1234567890;
auto sep2 = std::ranges::find(std::to_string(bad), '0');
std::cout << *sep2 << "\n";
You might recognize the problem here. If we weren't using range variants of the algorithms, the "bad" variant would crash at runtime. However, with ranges, this code will not compile. When a range-based algorithm is invoked with a temporary range that owns its elements, the algorithm will return a special iterator std::ranges::dangling.
Note that the first variant with a std::string_view will still work just fine. String view is a type of range that doesn't own its elements, and its iterators are freestanding (they don't depend on the instance of string_view), so it is perfectly valid to pass such temporary into a range-based algorithm.
To opt-in your ranges to work as temporaries, you need to specialize the enable_borrowed_range constant:
template<typename T>
inline constexpr bool
std::ranges::enable_borrowed_range<MyView<T>> = true;
One of the core problems with old STL algorithms is that they are not easily composable. As a result, the code using algorithms is often quite verbose and, when working with immutable data, requires additional copies.
Views are trying to address this issue, making code that relies on standard algorithms less verbose and more explicit.
Views are simply ranges that are cheap to copy and move (in constant time). Because of this, a view cannot own the elements it is viewing. One exception is std::views::single which owns the single element it is viewing.
Views compose at compile time with the expectation that the compiler will inline the code.
For example, the following code will print out the last three elements of the range. We first reverse the range, then take the first three elements, and finally reverse the range again (note that there is std::views::drop that does this directly).
namespace rv = std::ranges::views;
std::vector<int> dt = {1, 2, 3, 4, 5, 6, 7};
for (int v : rv::reverse(rv::take(rv::reverse(dt),3))) {
std::cout << v << ", ";
}
std::cout << "\n";
Because of the often deep nesting, the functional syntax of composing views can be cumbersome to write and read.
Fortunately, ranges bring us another approach for compositing views. Views in the std::views namespace are actually view closure objects. These are inline constexpr constants with each std::ranges::xxx_view mapping to an std::views::xxx object. These objects overload the operator() for functional syntax as seen above and operator| for pipe-style compositing.
namespace rv = std::ranges::views;
std::vector<int> dt = {1, 2, 3, 4, 5, 6, 7};
for (int v : dt | rv::reverse | rv::take(3) | rv::reverse) {
std::cout << v << ", ";
}
std::cout << "\n";
Note that while views do not own their elements, they do not change the mutability of underlying data. Here, we iterate over odd elements of the array and multiply them by two.
namespace rv = std::ranges::views;
std::vector<int> dt = {1, 2, 3, 4, 5, 6, 7};
auto odd = [](std::integral auto v) { return v % 2 == 1; };
for (auto& v : dt | rv::filter(odd)) {
v *= 2;
}
Let's have a look at some concrete examples of Ranges. We will write three versions of FizzBuzz:
As mentioned at the beginning of the article, the current support in C++20 is a bit lacking. Therefore, I will rely on the range v3 library.
Writing a coroutine FizzBuzz generator is almost identical to the typical implementation:
ranges::experimental::generator<std::string> fizzbuzz() {
for (int i = 1; ; i++) {
std::string result;
if (i % 3 == 0) result += "Fizz";
if (i % 5 == 0) result += "Buzz";
if (result.empty()) co_yield std::to_string(i);
else co_yield result;
}
}
However, if we use the generator<> from range v3 library, we can also use the invoked coroutine as a range.
for (auto s : fizzbuzz() | ranges::views::take(20)) {
std::cout << s << "\n";
}
The main magic here is in the implementation of the iterator type (note this code is not from range v3 library).
// Resume coroutine to generate new value.
void operator++() {
coro_.resume();
}
// Grab current value from coroutine.
const T& operator*() const {
return *coro_.promise().current_value;
}
// We are at the end if the coroutine is finished.
bool operator==(std::default_sentinel_t) const {
return !coro_ || coro_.done();
}
The std::default_sentinel_t is a convenience type provided by the standard, intended to be used for distinguishing comparisons against the end(). With this, we simply need to return this iterator from the generator<> return type:
Iter begin() {
if (coro_) {
coro_.resume();
}
return Iter{cor_};
}
std::default_sentinel_t end() {
return {};
}
We have quite a few options for the generative approach, the most obvious one being generate_n that will allow us to generate the output directly.
ranges::generate_n(
std::ostream_iterator<std::string>(std::cout, "\n"),
20,
[i = 0]() mutable {
i++;
std::string result;
if (i % 3 == 0) result += "Fizz";
if (i % 5 == 0) result += "Buzz";
if (result.empty()) return std::to_string(i);
return result;
});
Both of the previous approaches are very similar. They both implement FizzBuzz procedurally. However, we can also implement FizzBuzz in a completely different way.
FizzBuzz includes two cycles. Fizz with a period of three and Buzz with a period of five.
std::array<std::string, 3> fizz{"", "", "Fizz"};
std::array<std::string, 5> buzz{"", "", "", "", "Buzz"};
First, we need to turn these cycles into infinite ranges.
const auto inf_fizz = fizz | ranges::views::cycle;
const auto inf_buzz = buzz | ranges::views::cycle;
Then we can combine them using zip_with:
const auto inf_fizzbuzz = ranges::views::zip_with(
std::plus<>(),
inf_fizz,
inf_buzz);
Now we have an infinite range where each 3rd element is "Fizz", each 5th element is "Buzz", each 15th element is "FizzBuzz", and the rest are empty strings.
We are missing the plain numbers for the elements that are neither Fizz of Buzz. So let's construct an infinite range of indices (starting at one):
const auto indices = ranges::views::indices
| ranges::views::drop(1);
And finally, we need to put these two ranges together and output the final result.
const auto final_range = ranges::views::zip_with(
[](auto i, auto s) {
if (s.empty()) return std::to_string(i);
return s;
},
indices,
inf_fizzbuzz
);
ranges::copy_n(ranges::begin(final_range), 20,
std::ostream_iterator<std::string>(std::cout, "\n"));
All code examples and scripts are available at:
https://github.com/HappyCerberus/article-cpp20-ranges.
The range v3 library used for FizzBuzz examples is available at:
https://github.com/ericniebler/range-v3.
Thank you for reading this article. Did you enjoy it?
I also publish videos on YouTube. Do you have questions? Hit me up on Twitter or LinkedIn.
0