C++20 brought us some really cool new features, and ranges are definitely at the top of that list. If you’ve ever found yourself wrestling with complex algorithms or struggling to make your code more readable when working with collections, ranges are here to save the day.
Let’s dive into what ranges are all about. Think of them as a modern, more intuitive way to work with sequences of data. They’re like a superhero version of iterators, giving you more power and flexibility when manipulating collections.
One of the things I love most about ranges is how they make our code so much cleaner and easier to understand. Gone are the days of writing convoluted loops and dealing with iterator pairs. With ranges, we can express our intent more directly, making our code almost read like natural language.
For example, let’s say you want to find all the even numbers in a vector, square them, and then sum the results. Without ranges, you’d probably write something like this:
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int sum = 0;
for (const auto& num : numbers) {
if (num % 2 == 0) {
sum += num * num;
}
}
Now, let’s see how we can do the same thing using ranges:
#include <ranges>
#include <vector>
#include <iostream>
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
auto result = numbers | std::views::filter([](int n) { return n % 2 == 0; })
| std::views::transform([](int n) { return n * n; })
| std::ranges::fold_left(0, std::plus<>());
Isn’t that so much cleaner? It’s like we’re describing what we want to do in plain English: “Take the numbers, filter out the even ones, square them, and sum them up.” This is the beauty of ranges – they allow us to express complex operations in a more natural, readable way.
But ranges aren’t just about making our code prettier. They also bring some serious performance benefits to the table. Unlike traditional algorithms that often create intermediate containers, ranges can be lazy. This means they only do work when it’s actually needed, potentially saving a ton of memory and CPU cycles.
Let’s talk about some of the key components of ranges. First up, we have views. Views are lightweight, non-owning ranges that allow us to transform or filter data without creating new containers. They’re incredibly efficient because they don’t actually modify the underlying data – they just provide a different way of looking at it.
For instance, let’s say you have a vector of strings and you want to get a view of all the strings that start with the letter ‘A’. Here’s how you could do that:
std::vector<std::string> words = {"Apple", "Banana", "Avocado", "Cherry"};
auto a_words = words | std::views::filter([](const std::string& s) {
return !s.empty() && s[0] == 'A';
});
for (const auto& word : a_words) {
std::cout << word << '\n';
}
This will print “Apple” and “Avocado” without creating a new container. Pretty neat, right?
Another cool thing about ranges is how easily we can compose them. We can chain multiple operations together, creating complex transformations with minimal code. Let’s look at an example where we take a vector of integers, keep only the even numbers, multiply them by 2, and then take the first 3 results:
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
auto result = numbers | std::views::filter([](int n) { return n % 2 == 0; })
| std::views::transform([](int n) { return n * 2; })
| std::views::take(3);
for (int n : result) {
std::cout << n << ' '; // Outputs: 4 8 12
}
This level of composability makes ranges incredibly powerful for expressing complex algorithms in a clear and concise way.
Now, let’s talk about some of the common range adaptors that you’ll likely find yourself using a lot. We’ve already seen filter and transform, but there are many others:
- take: Limits the range to the first n elements
- drop: Skips the first n elements
- reverse: Reverses the order of elements
- join: Flattens a range of ranges
- zip: Combines multiple ranges into a single range of tuples
Here’s a fun example that uses a few of these:
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
std::vector<std::string> words = {"one", "two", "three", "four", "five"};
auto result = std::views::zip(numbers, words)
| std::views::drop(2)
| std::views::take(3);
for (const auto& [num, word] : result) {
std::cout << num << ": " << word << '\n';
}
This will output:
3: three
4: four
5: five
Pretty cool, right? We’ve zipped together our numbers and words, dropped the first two pairs, and then taken the next three.
One thing I really appreciate about ranges is how they encourage us to think about our data transformations in a more functional programming style. Instead of telling the computer step-by-step what to do, we’re describing the transformations we want to apply to our data. This often leads to code that’s not only more readable but also easier to reason about and maintain.
But it’s not all sunshine and rainbows. Like any powerful feature, ranges come with their own set of challenges. One of the biggest is that they can sometimes be a bit tricky to debug. Because ranges are often lazy-evaluated, the actual computation might not happen where you expect it to. This can make stepping through your code in a debugger a bit confusing at times.
Another potential gotcha is that ranges can sometimes lead to code that looks simple but actually has hidden complexity. For example, chaining together a bunch of view adaptors might look clean, but if you’re not careful, you could end up with operations that have unexpectedly poor performance characteristics.
That being said, these challenges are far outweighed by the benefits ranges bring to the table. They’re a powerful tool that, when used wisely, can make our C++ code more expressive, more efficient, and more fun to write.
Let’s dive into a slightly more complex example to see how ranges can simplify real-world scenarios. Imagine we have a collection of user objects, and we want to find the average age of all users who have made a purchase in the last month. Without ranges, this might involve multiple loops and temporary containers. With ranges, we can express this clearly and concisely:
struct User {
std::string name;
int age;
std::chrono::system_clock::time_point last_purchase;
};
std::vector<User> users = /* ... */;
auto now = std::chrono::system_clock::now();
auto one_month_ago = now - std::chrono::months{1};
auto recent_purchaser_ages = users
| std::views::filter([&](const User& u) {
return u.last_purchase > one_month_ago;
})
| std::views::transform([](const User& u) { return u.age; });
double average_age = std::ranges::fold_left(recent_purchaser_ages, 0.0, std::plus<>())
/ std::ranges::distance(recent_purchaser_ages);
This code is not only more readable than its loop-based equivalent, but it’s also more efficient. We’re not creating any intermediate containers, and we’re only iterating over the data once.
One of the things I’ve come to appreciate about ranges is how they encourage us to break down complex operations into smaller, more manageable pieces. This often leads to code that’s easier to test and maintain. For instance, in the example above, we could easily extract the filter and transform operations into named functions, making our intent even clearer:
auto is_recent_purchaser = [&](const User& u) {
return u.last_purchase > one_month_ago;
};
auto get_age = [](const User& u) { return u.age; };
auto recent_purchaser_ages = users
| std::views::filter(is_recent_purchaser)
| std::views::transform(get_age);
This approach makes our code more modular and easier to understand at a glance.
Another powerful aspect of ranges is how well they play with other C++20 features. For example, we can use them in combination with concepts to write generic algorithms that are both flexible and type-safe. Here’s a simple example of a function that finds the maximum element in any range of comparable objects:
template<std::ranges::input_range R>
requires std::totally_ordered<std::ranges::range_value_t<R>>
auto find_max(R&& range) {
return std::ranges::max_element(range);
}
This function will work with any range (like vectors, lists, or even views) as long as the elements can be compared. It’s a small example, but it shows how ranges can help us write more generic, reusable code.
As we wrap up, I want to emphasize that while ranges are a powerful tool, they’re not a silver bullet. Like any feature, they should be used judiciously. Sometimes a simple for loop is still the clearest way to express an algorithm, and that’s okay. The goal isn’t to use ranges everywhere, but to use them where they make our code clearer, more efficient, or more maintainable.
In my own work, I’ve found that ranges have been particularly useful for data processing tasks. They’ve allowed me to express complex transformations on large datasets in a way that’s both efficient and easy to understand. But I’ve also had to be careful not to go overboard – sometimes chaining too many operations together can make the code harder to follow.
As you start incorporating ranges into your own C++ code, my advice would be to start small. Try using them for simple transformations first, and gradually work your way up to more complex operations. Pay attention to how they affect the readability and performance of your code. And most importantly, have fun with them! Ranges open up new ways of thinking about data manipulation in C++, and exploring those can be really exciting.
Remember, the goal of using ranges (or any language feature, for that matter) is to make our lives as developers easier and our code better. If you find that ranges are helping you achieve that, then you’re on the right track. Happy coding!