What are Ranges?
At its core, a Range is a pair of iterators, delineating the start and the end of a sequence of elements. In a broader context, Ranges expand upon this idea to provide a new, more intuitive way to describe sequences and the operations performed on them. Think of them as “enhanced” sequences. Instead of working with raw iterators, you work with objects that represent a subsequence of elements, allowing for more readable and expressive code.
Why were Ranges introduced in C++20?
- Improved Readability: Traditional STL algorithms often required verbose syntax with explicit iterator pairs to denote operations on a sequence. Ranges introduced a cleaner and more intuitive syntax that allowed developers to express complex operations more concisely.
- Reduced Errors: Manually managing begin and end iterators was error-prone. It was easy to mismatch them or introduce off-by-one errors. Ranges encapsulate the sequence boundaries, mitigating such risks.
- Composability: With traditional STL, composing multiple operations could become unwieldy. Ranges, with their emphasis on lazy evaluation, make it seamless to chain multiple operations, producing more efficient and clearer code.
- Adaptability: Ranges offer adaptors, which can be thought of as sequence transformers. They allow sequences to be modified on-the-fly without altering the original data, offering a higher level of flexibility in algorithmic design.
The advantages of using Ranges over traditional STL algorithms:
- Expressiveness: Ranges offer a higher-level abstraction for sequences and algorithms, enabling developers to convey intent more clearly.
- Safety: By abstracting away raw iterator management, the potential for iterator-related errors is significantly diminished.
- Efficiency: Ranges’ lazy evaluation means computations are only performed when necessary. This often results in more optimal code, especially when chaining multiple operations.
- Flexibility: The ability to create custom views and adaptors allows for a tailored approach to problem-solving, paving the way for more idiomatic and adaptive C++ code.
- Integration with Modern C++ Features: Ranges are designed to work harmoniously with other C++20 features like concepts, making them integral to modern C++ development.
Setting Up the Development Environment
To explore C++20 and its features, such as Ranges, requires an appropriate development environment. Before you start writing and executing C++20 code, you need to ensure that you have the necessary tools and libraries in place. Let’s walk through the setup process to get started with C++20 and Ranges.
Necessary tools and libraries for using C++20:
- Compiler:
- GCC (GNU Compiler Collection): Ensure you have GCC version 10 or later, as it provides extensive support for C++20 features. You can download it from the official site.
- Clang: If you prefer Clang, make sure you’re using version 10 or newer. More details can be found on the official LLVM site.
- Build Systems (Optional but recommended):
- CMake: A popular choice for large projects, version 3.16 or later is recommended. You can fetch it from the official CMake site.
- Make: Traditional build system that pairs well with GCC.
- Standard Library:
- The compiler usually ships with its own version of the C++ standard library. For instance, GCC comes with libstdc++ and Clang with libc++.
- Integrated Development Environment (IDE) (Optional):
- Visual Studio Code: With the C++ extension, it’s a versatile and lightweight option for C++ development.
- CLion: A dedicated C++ IDE by JetBrains that provides robust C++20 support.
- Libraries:
- Range-v3: While C++20 includes many features of the range library, if you want to explore beyond that or if your compiler doesn’t yet fully support C++20 Ranges, you can use the range-v3 library by Eric Niebler. Find it on GitHub.
A simple program to ensure everything is set up correctly:
Now that you have your environment ready, let’s write a simple program to ensure everything is in place and works correctly.
#include <iostream>
#include <vector>
#include <ranges>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// Using C++20 Ranges to filter and transform the vector.
auto results = numbers | std::views::filter([](int n) { return n % 2 == 0; }) // Filter even numbers
| std::views::transform([](int n) { return n * n; }); // Square them
std::cout << "Squared even numbers: ";
for (auto n : results) {
std::cout << n << " ";
}
return 0;
}
Code language: C++ (cpp)
After writing the code, compile it using a C++20-enabled compiler:
For GCC:
g++ -std=c++20 your_filename.cpp -o output_name
Code language: Bash (bash)
For Clang:
clang++ -std=c++20 -stdlib=libc++ your_filename.cpp -o output_name
Code language: Bash (bash)
Run the resulting executable, and you should see the squared values of even numbers printed to the console. If this works smoothly, you’re all set and ready to dive into C++20 Ranges.
The Basics of Ranges
Diving into the Ranges library, one quickly discovers its depth and sophistication. However, before venturing into its intricacies, it’s essential to understand its foundational concepts. This section will shed light on the basics of Ranges, demystifying core ideas, and providing a foundational code example.
The core concept of view and action:
View:
- A view is essentially a lightweight wrapper around an existing sequence (like an array or a vector). It doesn’t own the underlying data but provides a different perspective or “view” of it.
- Views are lazy. They don’t do any computation until you access the elements. This means chaining multiple views doesn’t incur any overhead until you actually iterate over the resulting view.
- Being non-owning, views are cheap to copy but can become dangling if the underlying data they refer to is destroyed.
Action:
- While views describe new perspectives on data without modifying it, actions modify data in-place.
- An action is a composite of one or more views that applies its computations and produces new, modified data.
- Actions are eager, meaning they immediately apply their effects to the underlying data.
Range adapters: How they work and why they’re beneficial:
Range adapters are utilities that take in ranges and produce new ranges (usually views). They are essential building blocks for constructing complex operations in a readable and modular manner.
Benefits:
- Composability: Adapters can be chained together, allowing for the creation of complex operations through simple building blocks.
- Readability: By using descriptive adapter names, code becomes self-documenting.
- Efficiency: Since most adapters produce views, and views are lazy, you’re not incurring any computational cost until you actually need the results.
- Flexibility: They offer on-the-fly transformation of data without modifying the original dataset.
Code example: Basic use of a range:
Let’s illustrate these concepts with a code snippet:
#include <iostream>
#include <vector>
#include <ranges>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// Using a range adapter to get a view of the even numbers
auto evens = numbers | std::views::filter([](int n) { return n % 2 == 0; });
std::cout << "Even numbers: ";
for (auto n : evens) {
std::cout << n << " ";
}
// Chaining multiple adapters: Get the first 3 squared even numbers
auto firstThreeSquaredEvens = numbers | std::views::filter([](int n) { return n % 2 == 0; })
| std::views::transform([](int n) { return n * n; })
| std::views::take(3);
std::cout << "\nFirst three squared even numbers: ";
for (auto n : firstThreeSquaredEvens) {
std::cout << n << " ";
}
return 0;
}
Code language: C++ (cpp)
In this example, we first filtered the even numbers and then, in the next operation, filtered the even numbers, squared them, and took the first three results—all through the use of range adapters.
Understanding Range Adaptors
Range adaptors are tools that can be applied to ranges to produce new ranges. These adaptors can transform, filter, concatenate, and perform various other operations on the input range without modifying the original data. One of the most commonly used adaptors is views::transform
.
views::transform
The views::transform
adaptor is analogous to the std::transform
function in traditional STL but comes with the added benefit of lazy evaluation and a more concise syntax.
How it works:
views::transform
takes a unary function (or callable object) as an argument.- It returns a new view where each element is the result of applying the function to the corresponding element of the original range.
- The transformation is applied lazily, meaning that the function is not invoked until you access an element from the transformed view.
Code example: Transforming a range
Let’s consider a simple use case where we have a vector of integers and we want to produce a new range where each element is double the original value:
#include <iostream>
#include <vector>
#include <ranges>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5};
// Define a transformation function to double each number
auto doubled = [](int n) { return n * 2; };
// Apply the transformation using views::transform
auto transformed = numbers | std::views::transform(doubled);
std::cout << "Original numbers: ";
for (int n : numbers) {
std::cout << n << " ";
}
std::cout << "\nTransformed numbers (doubled): ";
for (int n : transformed) {
std::cout << n << " ";
}
return 0;
}
Code language: C++ (cpp)
Output:
Original numbers: 1 2 3 4 5
Transformed numbers (doubled): 2 4 6 8 10
Code language: plaintext (plaintext)
In this example, the views::transform
adaptor creates a new view, transformed
, without modifying the original numbers
vector. The transformation (doubling each number) is done on-the-fly as we iterate over the transformed
view.
views::filter
The views::filter
adaptor is one of the cornerstones of the Ranges library. It allows you to generate a new view by filtering elements of an existing range based on a predicate. This means you get a subsequence of the original range where each element satisfies a particular condition.
How it works:
views::filter
requires a predicate — a unary function (or callable object) that returns abool
.- The resulting view contains only those elements from the original range for which the predicate returns
true
. - Like other views, the filtering is applied lazily. This means the predicate is invoked only when you access an element from the filtered view.
Code example: Filtering elements of a range
Consider a scenario where we have a vector of integers, and we want to extract only the even numbers:
#include <iostream>
#include <vector>
#include <ranges>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// Define a predicate to check if a number is even
auto is_even = [](int n) { return n % 2 == 0; };
// Filter the range using views::filter
auto evens = numbers | std::views::filter(is_even);
std::cout << "Original numbers: ";
for (int n : numbers) {
std::cout << n << " ";
}
std::cout << "\nEven numbers: ";
for (int n : evens) {
std::cout << n << " ";
}
return 0;
}
Code language: C++ (cpp)
Output:
Original numbers: 1 2 3 4 5 6 7 8 9 10
Even numbers: 2 4 6 8 10
Code language: plaintext (plaintext)
In this example, the views::filter
adaptor creates a new view, evens
, which only includes the even numbers from the original numbers
vector. As we iterate over the evens
view, the is_even
predicate is applied to filter the results without modifying the source data.
views::take and views::drop
The Ranges library provides a plethora of adaptors that allow for a wide range of operations on data. Among these, views::take
and views::drop
are straightforward yet powerful tools to create subranges by limiting or skipping elements, respectively.
views::take:
- Produces a new view containing the first
N
elements of the original range. - If the original range contains fewer than
N
elements, the resulting view will contain all of them.
views::drop:
- Produces a view that skips the first
N
elements of the original range. - If the original range contains fewer than
N
elements, the resulting view will be empty.
Code example: Taking and dropping elements
Let’s illustrate the usage of views::take
and views::drop
on a vector of integers:
#include <iostream>
#include <vector>
#include <ranges>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// Use views::take to get the first 5 elements
auto first_five = numbers | std::views::take(5);
// Use views::drop to skip the first 5 elements
auto after_five = numbers | std::views::drop(5);
std::cout << "Original numbers: ";
for (int n : numbers) {
std::cout << n << " ";
}
std::cout << "\nFirst five numbers: ";
for (int n : first_five) {
std::cout << n << " ";
}
std::cout << "\nNumbers after the first five: ";
for (int n : after_five) {
std::cout << n << " ";
}
return 0;
}
Code language: C++ (cpp)
Output:
Original numbers: 1 2 3 4 5 6 7 8 9 10
First five numbers: 1 2 3 4 5
Numbers after the first five: 6 7 8 9 10
Code language: plaintext (plaintext)
This example demonstrates how you can easily use views::take
and views::drop
to create subviews of your data. The adaptors offer an intuitive means to access or ignore specific portions of a range, lending greater flexibility and readability to your C++ code. Whether you’re dealing with extensive data sets or small sequences, these adaptors can be indispensable for efficient data processing.
Chaining Range Adaptors
One of the most potent features of the Ranges library is the ability to chain multiple adaptors together, allowing for the composition of complex operations from simpler building blocks. By chaining, you can express intricate transformations and filters in a concise, readable manner.
Chaining is facilitated by the pipe (|
) operator. When you chain adaptors, each adaptor operates on the result of the previous one, and so on down the chain. This enables the creation of a transformation pipeline.
Code example: Combining multiple adaptors
Suppose you have a range of numbers, and you want to:
- Filter out the even numbers.
- Square the remaining numbers.
- Take the first three results.
Using the Ranges library, you can chain these operations together:
#include <iostream>
#include <vector>
#include <ranges>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// Define a predicate to check if a number is odd
auto is_odd = [](int n) { return n % 2 != 0; };
// Define a transformation function to square a number
auto squared = [](int n) { return n * n; };
// Chain the range adaptors
auto result = numbers | std::views::filter(is_odd)
| std::views::transform(squared)
| std::views::take(3);
std::cout << "Original numbers: ";
for (int n : numbers) {
std::cout << n << " ";
}
std::cout << "\nResult after chaining adaptors: ";
for (int n : result) {
std::cout << n << " ";
}
return 0;
}
Code language: C++ (cpp)
Output:
Original numbers: 1 2 3 4 5 6 7 8 9 10
Result after chaining adaptors: 1 9 25
Code language: plaintext (plaintext)
In the example above, we seamlessly combined three range adaptors to achieve the desired outcome, demonstrating the expressive power and clarity of the Ranges library. The adaptors operate on the data lazily, ensuring that each element is processed efficiently as you iterate through the resulting view.
Chaining adaptors offer a functional, declarative approach to data processing in C++, making your code more concise, readable, and often more performant. This paradigm shift in coding style can lead to more maintainable and understandable C++ programs.
Comparing Traditional Iterators and Ranges
Traditional iterators have been the backbone of the STL (Standard Template Library) for many years. They provide a way to navigate and manipulate sequences in a generic fashion. However, as with all tools, iterators have their own set of challenges.
Common pitfalls with traditional iterators:
- Iterator Invalidation: Modifying a container, especially operations like insertions and deletions, might invalidate some or all of the iterators to that container, leading to undefined behavior if those iterators are accessed.
- Mismatched Begin/End Iterators: Accidentally pairing the beginning iterator of one container with the end iterator of another can lead to undefined behavior.
- Verbosity: Writing algorithms with iterators can sometimes require a lot of boilerplate, especially if you’re composing multiple operations.
- Off-by-one Errors: It’s easy to introduce off-by-one errors, especially with loop boundaries.
- Difficulty in Expressing Complex Operations: To chain operations, temporary containers might be needed, which can be inefficient.
How ranges overcome these pitfalls:
- Safety: Ranges promote safer code by reducing the risk of errors like mismatched iterators. The entire range is represented as a single entity, so there’s no chance of mismatching begin and end.
- Expressiveness: Chaining operations becomes a breeze with the pipe (
|
) operator. This also leads to concise and readable code. - Lazy Evaluation: Ranges evaluate elements lazily, meaning operations are only computed when accessed, which can improve performance in some scenarios.
- Eliminating Temporary Containers: Chained operations using ranges don’t usually require temporary containers between steps, reducing potential overhead.
- Intuitive Operations: With named adaptors like
views::filter
andviews::transform
, the intent of the code becomes clearer.
Code example: Side-by-side comparison of traditional algorithms and their range equivalents:
Task: Given a vector of numbers, filter out even numbers, square the rest, and take the first three results.
Traditional Iterators:
#include <iostream>
#include <vector>
#include <algorithm>
#include <iterator>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
std::vector<int> result;
// Filtering and squaring
std::transform(numbers.begin(), numbers.end(), std::back_inserter(result),
[](int n) { return n * n; });
result.erase(std::remove_if(result.begin(), result.end(),
[](int n) { return n % 2 == 0; }), result.end());
// Taking first three
if (result.size() > 3) {
result.erase(result.begin() + 3, result.end());
}
for (int n : result) {
std::cout << n << " ";
}
return 0;
}
Code language: C++ (cpp)
Ranges:
#include <iostream>
#include <vector>
#include <ranges>
int main() {
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::views::take(3);
for (int n : result) {
std::cout << n << " ";
}
return 0;
}
Code language: C++ (cpp)
In the comparison, the Ranges approach is shorter, more expressive, and more intuitive. The pitfalls of traditional iterators, like the need for intermediate storage and the potential for mismatched iterators, are avoided.
Lazy Evaluation in Ranges
What is lazy evaluation?
Lazy evaluation is a programming concept where an expression or computation is delayed until its result is actually needed. Instead of computing values immediately when they’re defined, the computation happens “just-in-time” when the value is accessed or used.
Benefits of lazy evaluation in the context of Ranges:
- Efficiency: For large datasets, processing every element immediately can be wasteful, especially if only a subset of the data is eventually used. Lazy evaluation ensures that only the necessary elements are processed.
- Chainability: With lazy evaluation, multiple operations can be chained together without intermediate computations or storage. This leads to more concise code without the overhead of temporary containers.
- Memory Usage: Reduces memory footprint by not needing intermediate results to be stored. Only the final result, or the accessed parts of it, get computed and stored.
- Flexibility: Infinite ranges can be represented, as they are only evaluated when accessed.
- Potential Performance Gains: In many cases, combining multiple operations into one pass over the data (thanks to chained lazy evaluation) can be faster than separate passes for each operation.
Code example: Demonstrating lazy evaluation:
Let’s demonstrate the lazy nature of ranges using a simple example. We’ll create an infinite range of numbers, transform them, and then take just a few from the front.
#include <iostream>
#include <ranges>
#include <vector>
int main() {
// An infinite view of numbers starting from 1
auto numbers = std::views::iota(1);
// A transformation that prints when it's evaluated
auto verbose_transform = [](int n) {
std::cout << "Evaluating: " << n << "\n";
return n * n; // Squaring the number
};
// Apply the transformation and take the first 5
auto result = numbers | std::views::transform(verbose_transform)
| std::views::take(5);
// Print the final results
std::cout << "Final results: ";
for (int n : result) {
std::cout << n << " ";
}
return 0;
}
Code language: C++ (cpp)
Expected Output:
Evaluating: 1
Evaluating: 2
Evaluating: 3
Evaluating: 4
Evaluating: 5
Final results: 1 4 9 16 25
Code language: plaintext (plaintext)
In this example, you can observe the “Evaluating” print statements, which demonstrate that the transformation (squaring) is applied only when the result is actually accessed in the loop, not when the transformation is defined. This is a direct consequence of the lazy evaluation mechanism in action.
End-to-End Example: Practical Application of Ranges
Problem Statement:
You are given a log file where each line represents a log entry. Each log entry has a date, a log level (INFO, WARNING, ERROR), and a message. Your task is to extract all ERROR log messages from the last 30 days and present them in a concise report.
Sample Log Format:
2023-09-15 INFO Starting the application
2023-09-16 WARNING Connection timeout
2023-09-17 ERROR Failed to write to database
...
Code language: Access log (accesslog)
Step-by-step solution using Ranges:
- Read the log file into a collection of strings.
- Filter out entries older than 30 days.
- Filter out entries that aren’t labeled ERROR.
- Extract the date and message from the ERROR logs.
Range Solution:
#include <iostream>
#include <fstream>
#include <string>
#include <ranges>
#include <vector>
#include <chrono>
#include <sstream>
// Predicate to check if the log is from the last 30 days
bool is_recent(const std::string& log) {
std::string date_str = log.substr(0, 10); // Extract YYYY-MM-DD
std::tm t{};
std::istringstream ss(date_str);
ss >> std::get_time(&t, "%Y-%m-%d");
auto log_time = std::chrono::system_clock::from_time_t(std::mktime(&t));
auto now = std::chrono::system_clock::now();
auto days_past = std::chrono::duration_cast<std::chrono::days>(now - log_time);
return days_past.count() <= 30;
}
int main() {
std::ifstream log_file("log.txt");
if (!log_file.is_open()) {
std::cerr << "Failed to open log file." << std::endl;
return 1;
}
std::vector<std::string> logs;
for (std::string line; std::getline(log_file, line);) {
logs.push_back(line);
}
auto errors = logs | std::views::filter(is_recent)
| std::views::filter([](const std::string& log) {
return log.find("ERROR") != std::string::npos;
})
| std::views::transform([](const std::string& log) {
return log.substr(0, 10) + " - " + log.substr(17); // Date + Message
});
for (const auto& error : errors) {
std::cout << error << std::endl;
}
return 0;
}
Code language: C++ (cpp)
Traditional Approach:
#include <iostream>
#include <fstream>
#include <string>
#include <vector>
#include <chrono>
#include <sstream>
bool is_recent(const std::string& log) {
std::string date_str = log.substr(0, 10);
std::tm t{};
std::istringstream ss(date_str);
ss >> std::get_time(&t, "%Y-%m-%d");
auto log_time = std::chrono::system_clock::from_time_t(std::mktime(&t));
auto now = std::chrono::system_clock::now();
auto days_past = std::chrono::duration_cast<std::chrono::days>(now - log_time);
return days_past.count() <= 30;
}
int main() {
std::ifstream log_file("log.txt");
if (!log_file.is_open()) {
std::cerr << "Failed to open log file." << std::endl;
return 1;
}
std::vector<std::string> logs;
for (std::string line; std::getline(log_file, line);) {
logs.push_back(line);
}
for (const auto& log : logs) {
if (is_recent(log) && log.find("ERROR") != std::string::npos) {
std::cout << log.substr(0, 10) << " - " << log.substr(17) << std::endl;
}
}
return 0;
}
Code language: PHP (php)
Comparison:
While both solutions achieve the same goal, the Range solution demonstrates the expressive power of Ranges: filtering and transforming the logs are expressed declaratively, and the intent of the code is clear at first glance. The traditional approach, on the other hand, is more procedural and requires more careful reading to understand.
This example illustrates how C++20 Ranges can make data processing tasks more concise and readable without compromising on performance.
Advanced Ranges Concepts
Range factories: These are special utilities provided by the Ranges library that generate ranges on-the-fly, without necessarily having a backing container. They are particularly useful for generating sequences or repeated values. Two such factories are views::iota
and views::repeat_n
.
views::iota
:
views::iota
is a simple range factory that produces an infinite sequence of incrementing values, starting from the given value. When provided with one argument, it starts from that value and goes indefinitely. When given two arguments, it starts from the first value and stops at the second.
Code example for views::iota
:
#include <iostream>
#include <ranges>
int main() {
// Infinite sequence starting from 5
auto infinite_sequence = std::views::iota(5);
// Print first 10 values
for (int i : infinite_sequence | std::views::take(10)) {
std::cout << i << " ";
}
std::cout << "\n";
// Sequence from 5 to 10 (exclusive)
auto finite_sequence = std::views::iota(5, 10);
for (int i : finite_sequence) {
std::cout << i << " ";
}
return 0;
}
// Expected Output:
// 5 6 7 8 9 10 11 12 13 14
// 5 6 7 8 9
Code language: C++ (cpp)
views::repeat_n
:
views::repeat_n
is a range factory that produces a sequence of n repeated values.
Code example for views::repeat_n
:
#include <iostream>
#include <ranges>
int main() {
// Repeat the number 7, five times
auto repeated_values = std::views::repeat_n(7, 5);
for (int i : repeated_values) {
std::cout << i << " ";
}
return 0;
}
// Expected Output:
// 7 7 7 7 7
Code language: C++ (cpp)
views::join
and views::split
are two incredibly useful range adaptors in the C++20 Ranges library. They help in working with sequences and strings, especially when you want to flatten nested ranges or split a range based on some delimiter.
views::join
:
views::join
is used to flatten or concatenate a range of ranges. Think of it as a way to remove one level of nesting from a sequence.
Code example for views::join
:
#include <iostream>
#include <ranges>
#include <vector>
int main() {
// A vector of vectors
std::vector<std::vector<int>> nested_vectors = {{1, 2, 3}, {4, 5}, {6, 7, 8, 9}};
// Flatten the nested vectors
auto flattened = nested_vectors | std::views::join;
for (int i : flattened) {
std::cout << i << " ";
}
return 0;
}
// Expected Output:
// 1 2 3 4 5 6 7 8 9
Code language: C++ (cpp)
views::split
:
views::split
is used to split a range based on a specified delimiter. It creates a range of sub-ranges, each representing a piece between the delimiters.
Code example for views::split
:
#include <iostream>
#include <ranges>
#include <string_view>
int main() {
std::string_view text = "apple,banana,grape,orange";
// Splitting based on comma
auto fruits = text | std::views::split(',');
for (auto fruit : fruits) {
for (char c : fruit) {
std::cout << c;
}
std::cout << "\n";
}
return 0;
}
// Expected Output:
// apple
// banana
// grape
// orange
Code language: C++ (cpp)
views::join
and views::split
serve complementary purposes. While views::join
takes a range of ranges and flattens it into a single range, views::split
takes a single range and splits it into multiple sub-ranges based on a delimiter. They’re both powerful tools when dealing with sequences and text processing, especially when used alongside other range adaptors.
Custom Range adaptors
Creating custom range adaptors can allow you to add domain-specific processing to your data pipelines. This can be especially valuable when you find yourself repeating certain patterns across your application.
To make a custom range adaptor, you typically:
- Define a custom view that does the processing you need.
- Provide an adaptor function that makes it easy to use your view in a range pipeline.
Custom Range Adaptor Example: views::squared
Let’s create a range adaptor that squares each number in a range:
The Custom View
#include <ranges>
template<std::ranges::input_range R>
class squared_view : public std::ranges::view_interface<squared_view<R>> {
private:
R base_; // underlying range
public:
squared_view() = default;
squared_view(R base) : base_(std::move(base)) {}
auto begin() const {
return std::views::transform(base_, [](auto x) { return x * x; }).begin();
}
auto end() const {
return std::views::transform(base_, [](auto x) { return x * x; }).end();
}
};
Code language: C++ (cpp)
The Adaptor Function
This is a convenience function that makes it easier to use our custom view:
namespace custom_views {
template<std::ranges::input_range R>
squared_view<R> squared(R&& r) {
return squared_view<R>(std::forward<R>(r));
}
} // namespace custom_views
Code language: C++ (cpp)
Using Our Custom Range Adaptor
Now let’s see how we can use this adaptor:
#include <iostream>
#include <vector>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5};
// Using our custom range adaptor
for (int n : numbers | custom_views::squared) {
std::cout << n << " ";
}
return 0;
}
// Expected Output:
// 1 4 9 16 25
Code language: C++ (cpp)
With the custom range adaptor in place, you can easily square the values of any range by simply piping it through custom_views::squared
. The power of this pattern is evident when working on larger applications where specific, repeated transformations on data are necessary. By building and utilizing custom adaptors, you can make your data processing code concise and expressive.
Performance Considerations with Ranges
Ranges are a powerful and expressive tool for dealing with sequences in C++. While they often provide a more readable and composable approach to algorithmic operations, it’s crucial to understand their performance characteristics to use them efficiently.
How do Ranges perform compared to traditional algorithms?
- In many cases, comparable: For a majority of tasks, the performance of range-based code is on par with traditional algorithms, especially when the compiler optimizes the code. This is because range adaptors are designed to be composable and to avoid unnecessary intermediate operations.
- Lazy Evaluation: One of the significant benefits of Ranges is that many range adaptors use lazy evaluation. This means they only compute values when they’re accessed. This can lead to performance improvements, especially in situations where not all results of an operation are needed.
- Potential for inefficiencies: Due to the generic and composable nature of Ranges, there might be cases where naive use of range adaptors results in suboptimal performance, especially when creating complex chains of adaptors.
When to use and when not to use Ranges?
- Use when:
- You need better code readability and maintainability.
- You’re dealing with sequences where the composability of range adaptors can simplify code.
- You’re leveraging lazy evaluation, especially when working with large data sets or when only part of the result is needed.
- Avoid or reconsider when:
- Performance is critical, and benchmarking has shown a performance hit with Ranges.
- Interfacing with libraries or codebases that expect traditional iterators.
Best practices for optimizing Range performance:
- Be mindful of adaptor chaining: While chaining adaptors can be powerful, it can also lead to inefficiencies. Avoid long chains or deeply nested adaptors unless necessary.
- Beware of repeated evaluations: Since some ranges evaluate lazily, accessing the same element multiple times can result in repeated calculations. If you find yourself accessing values repeatedly, consider materializing the range into a container.
- Use
std::views::common
when necessary: This adaptor can transform a range into a common range, ensuring that bothbegin
andend
return the same type, which can make certain operations more efficient. - Benchmark: Before and after switching to Ranges, always benchmark critical code paths to ensure that there’s no unexpected performance degradation.
- Stay Updated: As compiler technology evolves, the optimization around Ranges will improve. It’s beneficial to keep your compiler updated to leverage these improvements.
Integrating Ranges with Other C++20 Features
C++20 brought a myriad of new features, some of which synergize well with the Ranges library. In this section, we’ll explore how Ranges can be integrated with Concepts and Coroutines.
Concept Checks with Ranges:
With the introduction of Concepts in C++20, we can now impose type constraints on template arguments, ensuring they satisfy certain properties. This is particularly useful with Ranges to ensure that the given arguments adhere to expected properties.
Code example using Concepts with Ranges:
Let’s write a function template that takes a range and requires that the range’s value type is arithmetic:
#include <iostream>
#include <ranges>
#include <vector>
#include <concepts>
template<std::ranges::input_range R>
requires std::integral<std::ranges::range_value_t<R>>
void print_integral_range(const R& r) {
for (const auto& val : r) {
std::cout << val << " ";
}
}
int main() {
std::vector<int> vec = {1, 2, 3, 4, 5};
print_integral_range(vec); // OK
// std::vector<double> vec_f = {1.1, 2.2, 3.3};
// print_integral_range(vec_f); // Error! Doesn't satisfy the integral concept
}
Code language: PHP (php)
In the above example, print_integral_range
is constrained to only accept ranges with integral value types.
Using Ranges with Coroutines:
Coroutines are a new way to write asynchronous code in a more readable and linear fashion. Combined with Ranges, they can be used to create lazy-generating sequences.
Code example using Ranges with Coroutines:
Let’s create a generator that yields an infinite sequence of numbers:
#include <iostream>
#include <ranges>
#include <experimental/coroutine>
#include <iterator>
template<typename T>
class generator {
public:
struct promise_type;
using handle_type = std::experimental::coroutine_handle<promise_type>;
generator(handle_type h) : coro(h) {}
generator(const generator&) = delete;
generator(generator&& rhs) : coro(rhs.coro) { rhs.coro = nullptr; }
~generator() { if (coro) coro.destroy(); }
struct promise_type {
T value;
auto get_return_object() {
return generator(handle_type::from_promise(*this));
}
std::experimental::suspend_always initial_suspend() { return {}; }
std::experimental::suspend_always final_suspend() noexcept { return {}; }
std::experimental::suspend_always yield_value(T v) {
value = v;
return {};
}
void return_void() {}
void unhandled_exception() { std::exit(1); }
};
struct sentinel {};
class iterator {
public:
using iterator_category = std::input_iterator_tag;
using value_type = T;
// Other typedefs...
iterator& operator++() {
coro.resume();
return *this;
}
const T& operator*() const { return coro.promise().value; }
bool operator!=(sentinel) const { return !coro.done(); }
iterator(handle_type h) : coro(h) {}
private:
handle_type coro;
};
iterator begin() {
coro.resume();
return iterator(coro);
}
sentinel end() { return {}; }
private:
handle_type coro;
};
generator<int> infinite_numbers(int start) {
while (true) {
co_yield start++;
}
}
int main() {
auto numbers = infinite_numbers(1);
for (auto i : numbers | std::views::take(5)) {
std::cout << i << " ";
}
// Outputs: 1 2 3 4 5
}
Code language: C++ (cpp)
In the above example, infinite_numbers
is a coroutine that generates an infinite sequence of numbers. We use Ranges with the std::views::take
adaptor to get the first 5 numbers.
Common Pitfalls and Troubleshooting with Ranges
Using the Ranges library, while powerful, can come with its set of challenges. Here are some common pitfalls developers might face, ways to troubleshoot them, and tips for smoother development.
Mistakes developers often make with Ranges:
- Overusing adaptor chaining: While chaining adaptors is one of the benefits of Ranges, excessively long chains can make code less readable and harder to debug.
- Misunderstanding laziness: Many range adaptors are lazy, meaning they don’t perform their work until the range is actually accessed. This can be a surprise for developers expecting eager behavior.
- Not materializing when needed: Lazy evaluation means the values are computed on-the-fly. If you’re going to access values multiple times, it might be beneficial to materialize the range into a container.
- Ignoring lifetime issues: Some range adaptors keep references or iterators to underlying ranges. If the underlying range gets destroyed or goes out of scope, accessing the adapted range leads to undefined behavior.
How to troubleshoot common errors:
- Compilation errors: Due to the templated nature of Ranges, a mistake can lead to verbose and intimidating compilation errors. To address this:
- Start Simple: Begin with a smaller piece of code, verify its correctness, and then expand.
- Concept checks: Use C++20 concepts to impose constraints on your functions. This can help in producing clearer error messages.
- Unexpected Results: If your range pipeline isn’t producing the expected results:
- Break it down: Decompose your range pipeline, examining the output at each stage.
- Use Debugging Utilities: Some libraries offer utilities for inspecting intermediate results in a pipeline.
- Performance Issues:
- Profile: Always profile your code to determine bottlenecks. Don’t make assumptions about which adaptors or operations are the culprits.
- Materialize when needed: If certain sections of your pipeline are slow, consider materializing intermediate results with
std::vector
or another container.
Tips for smooth development with Ranges:
- Understand underlying concepts: Before diving deep into Ranges, understand the basic concepts such as lazy evaluation, views, and actions.
- Keep lifetimes in mind: Always be aware of the lifetimes of your objects, especially when working with adaptors that hold references.
- Documentation is your friend: The official documentation or reference materials can provide invaluable insights and examples.
- Iterative Development: Especially when starting, build your range expressions incrementally. Verify at each step.
- Unit Testing: Ranges provide a more functional style of programming. This style often lends itself well to unit testing. Make sure to write tests for your range-based functions.
- Stay updated: The C++ community is vibrant, and new patterns, best practices, and even utilities related to Ranges are being developed. Follow blogs, talks, and forums to stay updated.
While Ranges offer a more concise and expressive way to deal with sequences, they come with their set of challenges. Being aware of these pitfalls and having strategies to troubleshoot will make your development experience smoother.