I'm a person that likes to dabble. I play around with something, learn some cool tricks, and typically, move on. My bread and butter, "pays the bills" programming language is C++. Unfortunately, C++ typically gets a bad rap for not being a "modern" programming language—which, truth be told, is fair since it does tend to lag the rest of the software world in features. Because of this, when I dabble in programming, I gravitate towards something modern and sleek, like Rust or F# (If you haven't tried F# yet, you're missing out; not on job opportunities, but certainly on a good time). In doing so, I pick up little habits that, while nice, are super painful when I go back to my beloved C++ and find the support for such features lacking.
One such feature is map
—and filter
and fold
(sometimes called reduce
) for that matter. In Rust, we can do
vec.iter().map(|x| x * 2);
and we've multiplied every item in a vector by 2... kind of. One of the awesome bonuses we get in a modern language like Rust is lazy evaluation. Lazy evaluation basically means, we don't evaluate our expression until we actually need it. In the case of our previous example, the x * 2
closure only runs if we do something that iterates over our mapped vector, and accesses the values. That means if you iterated over the vector and printed every other value, the closure is only evaluated for every other value. Rust has had this feature built into the standard library since 2015!
C++ Is Catching Up
As of the release of C++20 in December 2020, C++ now includes a version of these lazy evaluated iterables. They're included in the ranges
header. The ranges
header includes 2 important data types: range
s and view
s.
Ranges are basically just things that can be iterated over. It could be a container like a vector or list, a generator, or whatever as long as you can iterate over it.
A view is a special type of range. A view is a range that can be moved, copied, or assigned in constant time, and doesn't exercise ownership over it's underlying data.
The Evolution of C++
To explore how C++ has evolved and improved over the years and how C++20 ranges change the game, here's a little exercise. In the following snippets, I have some code that takes an array of integers, and returns a new array of integers containing only the even numbers from the input squared. Basically, the task is to find all the even numbers, then square them.
C++11
std::vector<int> squaredEvens(std::vector<int> const &v) {
std::vector<int> result;
result.reserve(v.size());
for (auto const &x : v) {
if (x % 2 == 0) {
result.push_back(pow(x, 2));
}
}
return result;
}
This one is pretty basic stuff. We create a vector, optionally reserve some known max capacity, loop through grabbing only our desired values, square them, and add them to our result.
C++11 with algorithm
std::vector<int> squaredEvens(std::vector<int> const &v) {
std::vector<int> result;
result.reserve(v.size());
std::copy_if(v.begin(),
v.end(),
std::back_inserter(result),
[](int const &x) { return x % 2 == 0; });
std::transform(result.begin(),
result.end(),
result.begin(),
[](int const &x) { return pow(x, 2); });
return result;
}
This one is... special. C++11 introduced the algorithm header which has maybe a few useful functions but is largely full of verbose nonsense like std::transform
or std::for_each
. This time we use std::copy_if
to copy only the values that meet our criteria into the result vector. Then we square everything in the result vector with std::transform
. The problem here should be obvious. It's wildly verbose—all this mention of begin
and end
and std::back_inserter
when really all we want to say is "Do this to every item in the list."
C++20 with ranges
std::vector<int> squaredEvens(std::vector<int> const &v) {
auto resultRange = v
| std::views::filter([](int const &x) { return x % 2 == 0; })
| std::views::transform([](int const &x) { return pow(x, 2); });
auto resultRangeCommon = std::views::common(resultRange);
std::vector<int> result;
std::ranges::copy(std::ranges::begin(resultRangeCommon),
std::ranges::end(resultRangeCommon),
std::back_inserter(result));
return result;
}
This is where things start to get interesting. Calculating the data for our answer becomes simple. The |
operator chains range operations together (basically function composition), so we can easily see that we start with v
, then apply our operations. Unfortunately, C++20 didn't include a good way to convert ranges back to a collection. We wind up with so much extra boilerplate just to convert between types that the entire readability benefit of ranges is lost.
C++23 with ranges
std::vector<int> squaredEvens(std::vector<int> const &v) {
return v
| std::views::filter([](int const &x) { return x % 2 == 0; })
| std::views::transform([](int const &x) { return pow(x, 2); })
| std::ranges::to<std::vector<int>>();
}
At last, the C++ gods have answered our prayers with std::ranges::to
. Basically, this new function can be chained onto a range to collect its values into some collection. With this one simple function, this aspect of C++ has been brought into the modern era (8 years late).
Wrapping Up
Ranges add a long delayed and much needed feature to C++. Although it was not until very recently that they were made a truly viable asset which means you're not that far behind the curve if you're reading this in 2023-ish.
If you're concerned with performance (and if you're writing any C++, you are), I've actually already run a primitive benchmark for the code snippets above. By and large, their run times are identical. That's right. There's no performance benefit to any of this. What you gain is a really beautiful syntax to show exactly how data is handled, and it's about time C++ programmers cared a bit more about readability.