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.
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::ranges::to<C>() | Materialise to container C (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