Master Ranges and Views for Lazy, Composable Data Pipelines
Build lazy, composable data pipelines in C++20 using ranges and views, replacing verbose iterator pairs with readable, zero-overhead transformations.
By the end of this page, you will understand what a range and a view are, compose multi-step data pipelines using the | operator, avoid the most common lifetime and evaluation-order pitfalls, and replace iterator-pair boilerplate with readable, zero-overhead transformations.
What and Why
Before C++20, transforming a sequence required either raw loops or passing paired iterators everywhere:
#include <algorithm>
#include <vector>
std::vector<int> evens;
std::copy_if(v.begin(), v.end(), std::back_inserter(evens),
[](int x){ return x % 2 == 0; });This allocates, copies, and forces you to name an intermediate container you never wanted. Worse, if you need three transformations you create three temporaries.
C++20 ranges give the standard library a unified concept of "something you can iterate." A view is a lightweight, non-owning object that describes a transformation lazily β nothing is computed until you actually iterate the view. The pipeline operator | wires views together left-to-right, producing code that reads like a sentence.
The key insight: a view does not hold elements. It holds only the logic to produce elements on demand. Constructing a five-stage pipeline costs O(1) time and O(1) space regardless of input size.
Step by Step
Step 1 β Your first range algorithm
Range algorithms live in std::ranges:: and accept a single range object instead of an iterator pair.
#include <algorithm>
#include <ranges>
#include <vector>
#include <iostream>
int main() {
std::vector<int> v = {5, 3, 1, 4, 2};
std::ranges::sort(v); // no .begin()/.end() needed
for (int x : v) std::cout << x << ' '; // 1 2 3 4 5
}std::ranges::sort is constrained by concepts β if you pass the wrong type you get a readable error, not a wall of template noise.
Step 2 β Your first view
#include <ranges>
#include <vector>
#include <iostream>
int main() {
std::vector<int> v = {1, 2, 3, 4, 5, 6};
auto evens = v | std::views::filter([](int x){ return x % 2 == 0; });
for (int x : evens) std::cout << x << ' '; // 2 4 6
}evens is not a std::vector. It is a lightweight adaptor wrapping v. No heap allocation occurs. Elements are filtered one at a time as the for loop advances the iterator.
Step 3 β Chaining views
Views compose with | exactly like Unix pipes.
#include <ranges>
#include <vector>
#include <iostream>
int main() {
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
auto pipeline = v
| std::views::filter([](int x){ return x % 2 == 0; }) // keep evens
| std::views::transform([](int x){ return x * x; }) // square them
| std::views::take(3); // first three
for (int x : pipeline) std::cout << x << ' '; // 4 16 36
}Each | returns a new view object; no element is produced until the final range-for iterates it.
Step 4 β Materialising a view into a container
When you do need a concrete container, use std::ranges::to (C++23) or std::ranges::copy:
#include <ranges>
#include <vector>
#include <algorithm>
#include <iterator>
int main() {
std::vector<int> src = {1, 2, 3, 4, 5};
// C++23 β cleanest
// auto result = src | std::views::transform([](int x){ return x * 2; })
// | std::ranges::to<std::vector>();
// C++20 β explicit copy
std::vector<int> result;
auto doubled = src | std::views::transform([](int x){ return x * 2; });
std::ranges::copy(doubled, std::back_inserter(result));
// result == {2, 4, 6, 8, 10}
}Common Patterns
Pattern 1 β Filtering and projecting with range algorithms
Range algorithms accept a projection β a callable applied to each element before comparison β eliminating the need for awkward lambdas over member access.
#include <algorithm>
#include <ranges>
#include <string>
#include <vector>
#include <iostream>
struct Employee { std::string name; int salary; };
int main() {
std::vector<Employee> staff = {
{"Alice", 90000}, {"Bob", 60000}, {"Carol", 75000}
};
std::ranges::sort(staff, std::less<>{}, &Employee::salary);
for (auto& e : staff) std::cout << e.name << ' '; // Bob Carol Alice
}The third argument to std::ranges::sort is the projection. No lambda wrapping needed β it reads like plain English.
Pattern 2 β Generating sequences with iota and building pipelines
std::views::iota produces an infinite (or bounded) sequence without any storage.
#include <ranges>
#include <iostream>
int main() {
// Sum of squares of first ten odd numbers
long long total = 0;
for (int x : std::views::iota(1)
| std::views::filter([](int n){ return n % 2 != 0; })
| std::views::transform([](int n){ return n * n; })
| std::views::take(10))
{
total += x;
}
std::cout << total << '\n'; // 1+9+25+...+361 = 1330
}The infinite iota(1) is safe here because take(10) short-circuits iteration.
Pattern 3 β Splitting and joining strings
#include <ranges>
#include <string_view>
#include <iostream>
int main() {
std::string_view csv = "apple,banana,cherry";
for (auto token : csv | std::views::split(',')) {
std::cout << std::string_view(token) << '\n';
}
// apple
// banana
// cherry
}std::views::split returns a range of ranges. Each sub-range is non-owning and refers into the original string.
Pattern 4 β Index alongside element with std::views::enumerate (C++23)
When you need both the zero-based position and the element value, std::views::enumerate produces pairs without a manual counter variable:
#include <ranges>
#include <vector>
#include <iostream>
int main() {
std::vector<std::string> fruits = {"apple", "banana", "cherry"};
for (auto const [index, fruit] : std::views::enumerate(fruits)) {
std::cout << index << ": " << fruit << '\n';
}
// 0: apple
// 1: banana
// 2: cherry
}Each element is a std::tuple<std::ptrdiff_t, T&>. The structured binding [index, fruit] names them conveniently. This is the C++23 equivalent of Python's enumerate() and replaces the pattern of maintaining a i = 0; ++i counter alongside a range-for.
Pattern 5 β Map keys and values with std::views::keys / std::views::values (C++20)
Iterating over only the keys or only the values of a map no longer requires a structured binding that discards half the pair:
#include <ranges>
#include <map>
#include <iostream>
int main() {
std::map<std::string, int> scores = {{"Alice", 90}, {"Bob", 75}, {"Carol", 85}};
for (const auto& name : std::views::keys(scores))
std::cout << name << '\n'; // Alice Bob Carol
for (int s : std::views::values(scores))
std::cout << s << '\n'; // 90 75 85
}std::views::keys is equivalent to std::views::transform([](auto& p){ return p.first; }) but with clearer intent. Both are C++20 and work on any range of pair-like elements.
Pattern 6 β Zipping two ranges with std::views::zip (C++23)
Before C++23, iterating two containers in lockstep required a manual index or a while loop keeping two iterators in sync:
// Old approach β error-prone, must track min size manually
std::vector<int> prices = {100, 200, 150};
std::vector<int> costs = {10, 20, 50};
for (std::size_t i = 0; i < std::min(prices.size(), costs.size()); ++i)
std::cout << prices[i] - costs[i] << ' '; // 90 180 100std::views::zip (C++23) does this in one expression. It produces a view of std::pair (for two ranges) or std::tuple (for three or more), stopping at the shortest range:
#include <ranges>
#include <vector>
#include <iostream>
int main() {
std::vector<std::string> names = {"Alice", "Bob", "Carol"};
std::vector<int> scores = {90, 75, 85};
for (auto const& [name, score] : std::views::zip(names, scores))
std::cout << name << ": " << score << '\n';
// Alice: 90
// Bob: 75
// Carol: 85
}Structured bindings unpack the pair directly. For three ranges, zip yields std::tuple<T0&, T1&, T2&> β useful for Structure-of-Arrays (SOA) layouts where related data lives in separate vectors.
std::views::zip_transform(fn, r1, r2) is a companion that applies a callable element-wise rather than producing a tuple, acting as a lazy std::transform over multiple ranges simultaneously.
Pattern 7 β Chunking and Sliding Windows (C++23)
Two new adaptors handle the common need to process a range in fixed-size batches or overlapping windows.
std::views::chunk(n) splits a range into consecutive non-overlapping groups of n elements. The last group may be smaller if the range size is not divisible by n:
#include <ranges>
#include <vector>
#include <iostream>
int main() {
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8};
for (auto const& chunk : v | std::views::chunk(3)) {
std::cout << '[';
for (int x : chunk) std::cout << x << ' ';
std::cout << "]\n";
}
// [1 2 3]
// [4 5 6]
// [7 8]
}std::views::slide(n) produces overlapping windows of n consecutive elements β the "sliding window" pattern. Each element of the result view is itself a view into the original range:
#include <ranges>
#include <vector>
#include <iostream>
int main() {
std::vector<int> v = {1, 2, 3, 4, 5};
for (auto const& window : v | std::views::slide(3)) {
for (int x : window) std::cout << x << ' ';
std::cout << '\n';
}
// 1 2 3
// 2 3 4
// 3 4 5
}A compile-time variant, std::views::adjacent<N>, produces tuples of N references instead of sub-views, allowing structured bindings like auto [a, b, c].
std::views::chunk_by(pred) is a predicate-based variant: it splits on every adjacent pair for which the binary predicate returns false, grouping runs of "related" elements together:
std::vector<int> v = {1, 1, 2, 3, 5, 5, 6};
// Group consecutive equal values
for (auto const& run : v | std::views::chunk_by(std::equal_to<>{})) {
for (int x : run) std::cout << x << ' ';
std::cout << '\n';
}
// 1 1
// 2
// 3
// 5 5
// 6Pattern 8 β Joining a range of ranges with a separator using std::views::join_with (C++23)
std::views::split breaks a sequence into sub-ranges. std::views::join_with (C++23) is its inverse: it flattens a range of ranges into a single sequence and inserts a delimiter between each inner range.
#include <ranges>
#include <vector>
#include <string>
#include <string_view>
#include <iostream>
int main() {
std::vector<std::string_view> words = {"apple", "banana", "cherry"};
// join_with inserts the delimiter between each word
for (char c : words | std::views::join_with(std::string_view(", ")))
std::cout << c;
// apple, banana, cherry
}The delimiter can be a single element (',') or a range of elements (", "). To materialise the result into a std::string, use the std::from_range constructor tag (C++23):
std::string result(std::from_range,
words | std::views::join_with(std::string_view(" ")));
// result == "apple banana cherry"join_with is the complement of split and composes naturally with transform: first transform each word, then join with a separator, all lazily with no intermediate allocations.
Writing a Custom View Adaptor
C++23 formalises the range adaptor closure object concept: any type that inherits from std::ranges::range_adaptor_closure<Derived> gains the | pipe operator for free, so it slots into pipelines just like library views.
The pattern: store the adaptor's configuration in the closure object, then implement operator() taking the incoming range:
#include <ranges>
#include <vector>
#include <functional>
#include <iostream>
// A reusable "take_while" adaptor that works with operator|
template <typename Pred>
struct take_while_closure
: std::ranges::range_adaptor_closure<take_while_closure<Pred>>
{
Pred pred;
explicit take_while_closure(Pred p) : pred(std::move(p)) {}
// Called when the user writes range | take_while_closure{pred}
template <std::ranges::viewable_range R>
auto operator()(R&& r) const {
return std::forward<R>(r) | std::views::take_while(pred);
}
};
// Factory function β mirrors the style of standard library adaptors
auto take_while_positive() {
return take_while_closure{[](int x){ return x > 0; }};
}
int main() {
std::vector<int> v = {3, 1, 4, -1, 5, 9};
for (int x : v | take_while_positive())
std::cout << x << ' '; // 3 1 4
}Inheriting from std::ranges::range_adaptor_closure<T> (C++23, standardised from P2387) is the only requirement. The base class provides operator| overloads so that both range | adaptor and adaptor | adaptor compose correctly. Before C++23, this was done by manually writing friend auto operator|(R&& r, Closure c).
What Can Go Wrong
Dangling views from temporaries
A view does not own its elements. If the underlying container is destroyed before the view is iterated, you have undefined behaviour.
// WRONG β temporary destroyed before loop runs
auto bad_view = std::vector<int>{1, 2, 3} | std::views::filter([](int x){ return x > 1; });
for (int x : bad_view) { /* UB: vector is gone */ }
// CORRECT β keep the source alive
std::vector<int> data = {1, 2, 3};
auto safe_view = data | std::views::filter([](int x){ return x > 1; });
for (int x : safe_view) std::cout << x << ' ';The rule: a view must not outlive its source range.
Mutating the source while iterating a cached view
Some views (notably std::views::filter) cache the begin iterator on first access for performance. Inserting or erasing from the source container after constructing the view invalidates that cache silently.
std::vector<int> v = {1, 2, 3, 4};
auto view = v | std::views::filter([](int x){ return x % 2 == 0; });
v.push_back(6); // may invalidate internal cache
for (int x : view) { } // potentially incorrect resultsReconstruct the view after mutating the source.
Expecting operator[] on every view
Not all views are random-access. A filter view is bidirectional at best; indexing it requires std::ranges::advance or materialising to a vector first.
auto filtered = v | std::views::filter([](int x){ return x > 2; });
// filtered[2]; // compile error β filter_view does not support operator[]Check the range category (random_access_range, bidirectional_range, etc.) when you need indexed access.
Quick Reference
| View / Algorithm | What it does | Category |
|---|---|---|
std::views::filter(pred) | Keep elements satisfying pred | Adaptor |
std::views::transform(fn) | Apply fn to each element | Adaptor |
std::views::take(n) | First n elements | Adaptor |
std::views::drop(n) | Skip first n elements | Adaptor |
std::views::iota(start) | Infinite integer sequence | Factory |
std::views::split(delim) | Split range on delimiter | Adaptor |
std::views::reverse | Reverse traversal | Adaptor |
std::ranges::sort(r, cmp, proj) | Sort with optional projection | Algorithm |
std::ranges::copy(r, out) | Materialise view to output | Algorithm |
std::views::enumerate(r) | (index, element) pairs (C++23) | Adaptor |
std::views::keys(r) | First element of each pair (C++20) | Adaptor |
std::views::values(r) | Second element of each pair (C++20) | Adaptor |
std::ranges::to<C>() | Materialise to container C (C++23) | Adaptor |
std::views::zip(r1, r2, ...) | Lockstep iteration as pairs/tuples (C++23) | Adaptor |
std::views::zip_transform(fn, ...) | Element-wise transform over multiple ranges (C++23) | Adaptor |
std::views::chunk(n) | Non-overlapping N-element batches (C++23) | Adaptor |
std::views::slide(n) | Overlapping N-element windows (C++23) | Adaptor |
std::views::adjacent<N> | Overlapping N-tuples, compile-time N (C++23) | Adaptor |
std::views::chunk_by(pred) | Split on adjacent predicate boundary (C++23) | Adaptor |
std::views::join_with(delim) | Flatten range of ranges with delimiter (C++23) | Adaptor |
Lifetime rule: views are non-owning β the source must outlive every view built on it.
Laziness rule: no work happens until you iterate; chaining views is free.
Projection rule: range algorithms accept a projection as a trailing argument, eliminating boilerplate member-access lambdas.
What's Next
- Deepen your understanding of the constraints that make ranges safe at compile time: Concepts β Advanced
- Explore how
if constexprcan branch on range category inside generic code: constexpr if β Advanced - Write custom view adaptors using stateful lambdas: Lambdas β Advanced
- Browse the full catalogue of standard range adaptors and algorithms: Ranges and Views Reference