C++20 Ranges Library: A Deep Dive into Modern Data Processing
Master C++20 Ranges: lazy evaluation, views, adaptors, and pipeline composition.
20+ years shipping performance-critical C and C++ systems. Notes here come from systems that actually shipped.
- ✓C++ basics (variables, functions, classes)
- ✓STL containers (vector, string)
- ✓Lambda expressions
- ✓Basic understanding of iterators
- Ranges provide a new way to work with sequences using composable algorithms.
- Views are lazy, non-owning transformations that don't allocate.
- Pipe operators (|) allow chaining operations like Unix pipelines.
- Range adaptors like filter, transform, and take enable declarative data processing.
- Ranges integrate seamlessly with existing STL containers and algorithms.
Think of Ranges like a factory conveyor belt. Instead of moving items to different machines (algorithms) one by one, you set up a pipeline: items flow through filters, transformers, and packers automatically. The belt doesn't even run until you ask for the final product (lazy evaluation). This saves time and memory because you don't create intermediate piles of parts.
In C++20, the Ranges library (std::ranges) revolutionized how we process sequences. Before Ranges, you'd write nested loops or chain algorithms with iterators, often creating temporary containers. Ranges introduce a declarative, composable style: you describe what you want (filter odd numbers, square them, take first 5) and the library handles the how. This leads to cleaner, less error-prone code. Ranges are built on three pillars: ranges (the sequences), views (lazy transformations), and actions (eager modifications). Views are the star: they don't own data, don't allocate, and compose via the pipe operator (|). For example, auto result = numbers | views::filter([](int n){ return n % 2 == 0; }) | views::transform([](int n){ return n * n; }); creates a pipeline that only evaluates when iterated. This tutorial covers everything from basic views to advanced topics like generating infinite sequences and custom adaptors. You'll learn how to write production-ready code that's both efficient and expressive.
1. Core Concepts: Ranges, Views, and Actions
The Ranges library introduces three key abstractions: ranges, views, and actions. A range is anything that has a begin and end iterator (containers, arrays, even istreams). A view is a lightweight range that doesn't own data; it's a lazy transformation or a window into another range. Actions are eager operations that modify a range in-place (like sort). The pipe operator | chains views and actions, creating a pipeline. For example:
```cpp #include <ranges> #include <vector> #include <iostream>
int main() { std::vector<int> numbers = {1, 2, 3, 4, 5, 6}; auto even_squares = numbers | std::views::filter([](int n) { return n % 2 == 0; }) | std::views::transform([](int n) { return n * n; }); for (int n : even_squares) { std::cout << n << ' '; } // Output: 4 16 36 } ```
Views are lazy: the filter and transform are only applied when you iterate. This avoids intermediate allocations. The pipeline is composable and readable.
std::ranges::to.2. Common Range Adaptors: filter, transform, take, drop
The standard provides many adaptors. filter keeps elements satisfying a predicate. transform applies a function to each element. take takes the first N elements; drop skips the first N. reverse reverses the view. elements extracts tuple-like elements. Example:
```cpp #include <ranges> #include <vector> #include <iostream> #include <string>
int main() { std::vector<std::string> words = {"apple", "banana", "cherry", "date"}; auto result = words | std::views::filter([](const std::string& s) { return s.size() > 4; }) | std::views::transform(&std::string::size) | std::views::take(2); for (auto len : result) { std::cout << len << ' '; // Output: 5 6 (apple, banana) } } ```
You can also use std::views::common to convert a view to a common range (same begin/end type) for legacy algorithms.
3. Lazy Evaluation and Infinite Ranges
Views are lazy, meaning they compute elements on demand. This enables infinite ranges like std::views::iota (generate integers) and std::views::repeat (repeat a value). Combined with take, you can create finite sequences. Example:
```cpp #include <ranges> #include <iostream>
int main() { auto squares = std::views::iota(1) | std::views::transform([](int n) { return n * n; }) | std::views::take(10); for (int s : squares) { std::cout << s << ' '; } // Output: 1 4 9 16 25 36 49 64 81 100 } ```
iota generates an infinite sequence starting from 1. The pipeline only computes the first 10 squares. This is memory-efficient and elegant.
iota for generating test data or indices without allocating a container.take to limit the output.4. Composing Views with Pipe Operator
The pipe operator | is the heart of Ranges composability. It allows chaining views left-to-right, similar to Unix pipes. The left operand is a range, the right is a view adaptor (or action). You can also create your own adaptors using std::ranges::views::transform with a lambda. Example:
``cpp auto custom_view = std::views::transform([](int x) { return x * 2; }); auto pipeline = numbers | custom_view | std::views::filter(...); ``
You can also use std::views::all to turn any range into a view. The pipe operator works with any range, including C-style arrays.
auto step1 = data | transform; auto step2 = step1 | filter;5. Actions: Eager Modifications
Actions are eager operations that modify a range in-place. They are in std::ranges::actions namespace. Common actions include sort, reverse, unique, shuffle. Actions also use pipe syntax. Example:
```cpp #include <ranges> #include <vector> #include <iostream> #include <algorithm>
int main() { std::vector<int> data = {5, 3, 1, 4, 2}; data |= std::ranges::actions::sort; for (int v : data) std::cout << v << ' '; // 1 2 3 4 5 } ```
Actions modify the container in-place and return a reference to it. They are useful when you want to mutate a container.
data | views::filter(...) | actions::sort sorts the filtered view? No, actions work on mutable ranges. Be careful: data | views::filter(...) returns a view, not a mutable container, so you cannot apply actions directly. Use std::ranges::actions::sort(data) instead.6. Custom Range Adaptors
You can create custom view adaptors using std::ranges::views::transform or by deriving from std::ranges::view_interface. For simple cases, a lambda works. For complex adaptors, you can define a class that satisfies the view concept. Example of a custom adaptor that adds a constant:
```cpp #include <ranges> #include <iostream>
class add_constant_view : public std::ranges::view_interface<add_constant_view> { std::vector<int> vec; int constant; public: add_constant_view(std::vector<int> v, int c) : vec(std::move(v)), constant(c) {} auto begin() { return vec.begin(); } auto end() { return vec.end(); } // ... but this is not lazy. For a lazy view, you'd wrap another view. }; ```
A simpler approach: use std::views::transform with a captured value. For production, prefer composition over custom adaptors unless necessary.
auto add(int n) { return std::views::transform([n](int x){ return x+n; }); }7. Performance Considerations and Materialization
Views are cheap: they don't allocate, and transformations are applied per element during iteration. However, if you iterate the same view multiple times, the transformations are recomputed each time. To avoid this, you can materialize the view into a container using std::ranges::to. Example:
```cpp #include <ranges> #include <vector> #include <iostream>
int main() { std::vector<int> data = {1, 2, 3, 4, 5}; auto view = data | std::views::transform([](int x) { return x * 2; }); // First iteration for (int v : view) std::cout << v << ' '; // Second iteration: recomputes transform for (int v : view) std::cout << v << ' '; // Materialize once auto vec = view | std::ranges::to<std::vector>(); for (int v : vec) std::cout << v << ' '; } ```
Also, be aware that some views like views::reverse may have overhead. Profile when performance matters.
std::views::cache_last (C++23) to memoize the last element, but for now, materialize or use raw loops.8. Integration with STL Algorithms and Containers
Ranges work seamlessly with STL containers. You can pass a view to std::ranges::sort, std::ranges::find, etc. Many algorithms have range overloads. Example:
```cpp #include <ranges> #include <vector> #include <algorithm> #include <iostream>
int main() { std::vector<int> data = {5, 2, 8, 1, 9}; auto even_view = data | std::views::filter([](int x) { return x % 2 == 0; }); // Find first even number auto it = std::ranges::find(even_view, 2); if (it != even_view.end()) { std::cout << *it << ' '; // 2 } // Sort the original container std::ranges::sort(data); for (int v : data) std::cout << v << ' '; // 1 2 5 8 9 } ```
You can also use std::views::common to convert a view to a common range for legacy algorithms that expect same iterator types.
std::ranges::subrange or std::views::common to bridge.The Lazy Evaluation That Crashed a Trading System
views::filter was applied but never iterated; the view was passed to a function that expected a container, causing the filter to be ignored.std::ranges::to<std::vector>() before passing to the legacy function.- Always understand when a view is lazy vs eager.
- Use
std::ranges::toto materialize views when needed. - Prefer views for pipelines that are consumed immediately; materialize for storage.
- Document pipeline boundaries where evaluation occurs.
- Test with realistic data sizes to catch lazy evaluation surprises.
views::cache_last for expensive computations.auto for intermediate views; check type constraints.std::views::all on temporaries carefully.std::ranges::empty(source)for (auto e : pipeline) std::cout << e;| File | Command / Code | Purpose |
|---|---|---|
| basic_pipeline.cpp | int main() { | 1. Core Concepts |
| adaptors.cpp | int main() { | 2. Common Range Adaptors |
| infinite.cpp | int main() { | 3. Lazy Evaluation and Infinite Ranges |
| pipe.cpp | int main() { | 4. Composing Views with Pipe Operator |
| actions.cpp | int main() { | 5. Actions |
| custom_adaptor.cpp | int main() { | 6. Custom Range Adaptors |
| materialize.cpp | int main() { | 7. Performance Considerations and Materialization |
| integration.cpp | int main() { | 8. Integration with STL Algorithms and Containers |
Key takeaways
std::ranges::to when needed.Common mistakes to avoid
4 patternsAssuming views are eager and materialize immediately.
Using `auto` to store a view that references a temporary.
Applying actions to a view instead of a container.
Forgetting to include `<ranges>` header.
Interview Questions on This Topic
Explain the difference between `std::views::filter` and `std::ranges::actions::sort`.
filter is a lazy view that selects elements satisfying a predicate without modifying the source. sort is an eager action that sorts the container in-place.Frequently Asked Questions
20+ years shipping performance-critical C and C++ systems. Notes here come from systems that actually shipped.
That's C++ Advanced. Mark it forged?
4 min read · try the examples if you haven't