10. Ranges & Views¶
What & why¶
C++20 ranges turn the iterator-pair algorithms into composable, lazy pipelines. Instead of building intermediate vectors at each step, you describe a pipeline of views that computes elements on demand as you iterate:
auto result = numbers
| std::views::filter([](int n) { return n % 2 == 0; })
| std::views::transform([](int n) { return n * n; })
| std::views::take(5);
Nothing is computed until you iterate result, and no temporary containers are created.
Ranges and views¶
- A range is anything with
begin()andend(): containers, arrays, strings, views. - A view is a lightweight range that doesn't own its elements (or owns them cheaply), with O(1) copy/move. Views are lazy: they compute elements when iterated.
- A range adaptor (
std::views::filter,transform, ...) takes a range and produces a view. Adaptors compose with|.
#include <iostream>
#include <ranges>
#include <vector>
int main() {
std::vector<int> v{1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
auto evens_squared = v | std::views::filter([](int n) { return n % 2 == 0; })
| std::views::transform([](int n) { return n * n; });
for (int x : evens_squared) {
std::cout << x << ' '; // 4 16 36 64 100
}
}
The standard views¶
Sources (range factories)¶
std::views::iota(1, 11) // 1, 2, ..., 10
std::views::iota(0) // 0, 1, 2, ... (infinite)
std::views::repeat(7, 3) // 7, 7, 7 (C++23)
std::views::single(42) // just 42
std::views::empty<int> // nothing
std::views::istream<int>(std::cin) // ints read lazily from a stream
Adaptors¶
views::filter(pred) // keep matching elements
views::transform(fn) // map each element
views::take(n) views::drop(n) // first n / skip n
views::take_while(p) views::drop_while(p)
views::reverse // needs a bidirectional range
views::keys views::values // .first / .second of pairs (maps!)
views::elements<I> // I-th element of tuples
views::split(delim) // split into subranges
views::join // flatten a range of ranges
views::common // same begin/end type, for legacy algorithms
// C++23:
views::enumerate // (index, element) pairs
views::zip(a, b) // (a[i], b[i]) tuples
views::chunk(n) views::slide(n) views::stride(n)
views::adjacent<N> views::pairwise
views::join_with(delim)
views::cartesian_product(a, b)
views::as_const views::as_rvalue
(namespace views = std::views; is conventional.)
Examples¶
namespace views = std::views;
// First 10 squares of odd numbers
for (int x : views::iota(1) | views::filter([](int n) { return n % 2; })
| views::transform([](int n) { return n * n; })
| views::take(10)) { ... }
// Iterate a map's keys
std::map<std::string, int> ages{{"Ada", 36}, {"Alan", 41}};
for (const auto& name : ages | views::keys) { ... }
// Reverse without copying
for (int x : v | views::reverse) { ... }
// Split a string into words (each word is a subrange of chars)
std::string_view text = "the quick brown fox";
for (auto word : text | views::split(' ')) {
std::cout << std::string_view{word} << '\n'; // C++23: string_view from a contiguous subrange
}
// C++23: index + value, and parallel iteration
for (auto [i, name] : names | views::enumerate) { ... }
for (auto [name, score] : views::zip(names, scores)) { ... }
Materializing: std::ranges::to (C++23)¶
Views are lazy; to get a container, collect them:
auto squares = views::iota(1, 6)
| views::transform([](int n) { return n * n; })
| std::ranges::to<std::vector>(); // std::vector<int>{1, 4, 9, 16, 25}
auto words = text | views::split(' ')
| views::transform([](auto r) { return std::string(r.begin(), r.end()); })
| std::ranges::to<std::vector>();
Before C++23 (or without library support):
auto view = v | views::filter(pred);
std::vector<int> out(view.begin(), view.end()); // may need views::common for some views
Ranges algorithms¶
Every <algorithm> function has a std::ranges:: version that takes a range, supports projections, and returns more useful results:
std::ranges::sort(people, {}, &Person::age);
auto it = std::ranges::find(people, "Ada", &Person::name);
auto [min, max] = std::ranges::minmax(v);
bool ok = std::ranges::all_of(v | views::take(3), is_positive);
// C++23 folds
int total = std::ranges::fold_left(v, 0, std::plus<>{});
Algorithms are eager and can modify; views are lazy and (mostly) read-only. Combine them: filter/transform with views, then sort/accumulate with algorithms.
Laziness and its consequences¶
Views re-evaluate on each iteration¶
auto expensive = v | views::transform([](int n) { return slow_compute(n); });
for (int x : expensive) { ... } // calls slow_compute for every element
for (int x : expensive) { ... } // calls it AGAIN for every element
If you'll iterate more than once, materialize with std::ranges::to.
filter caches begin() — and can't be const-iterated¶
views::filter (and drop_while, reverse on some ranges) caches the result of begin() for amortized O(1). Consequences:
- You can't iterate a
constfilter view: take view parameters by value orauto&&, notconst auto&. - Modifying the underlying elements so they no longer match the predicate after
begin()was cached is undefined behavior.
void print_all(std::ranges::input_range auto&& r) { // not const auto&
for (const auto& x : r) std::cout << x << ' ';
}
Dangling¶
A view refers to its underlying range. If that range is a temporary, the view dangles:
auto get_view() {
std::vector<int> local{1, 2, 3};
return local | views::filter(is_even); // dangling: local dies here
}
std::ranges algorithms protect you partially: calling std::ranges::find(make_vector(), 3) on a temporary returns std::ranges::dangling instead of an iterator, making misuse a compile error. Views over temporaries of owning types (C++23 owning_view) are fine: make_vector() | views::filter(...) moves the vector into the view.
Writing your own range adaptor¶
Composing existing adaptors is the easy way:
inline constexpr auto evens = views::filter([](int n) { return n % 2 == 0; });
inline constexpr auto squared = views::transform([](int n) { return n * n; });
auto r = v | evens | squared; // adaptors are themselves composable objects
auto evens_squared = evens | squared; // compose adaptors without a range
For a truly new view, derive from std::ranges::view_interface and provide an iterator — or, simpler for generators, return a std::generator (C++23), which is itself a view:
std::generator<std::string_view> lines(std::string_view text) {
while (!text.empty()) {
auto nl = text.find('\n');
co_yield text.substr(0, nl);
if (nl == std::string_view::npos) break;
text.remove_prefix(nl + 1);
}
}
for (auto line : lines(file_contents) | views::filter(non_empty) | views::take(10)) { ... }
Performance¶
Views generally compile to code as fast as hand-written loops — the pipeline is inlined. But:
- Long pipelines increase compile times.
filterfollowed bytransformfollowed byfiltercan evaluate the transform function more than once per element in some compositions (e.g.transform | filtercalls the transform in both the predicate check and the dereference).- Debug builds are much slower than raw loops (many small function calls).
Measure hot paths as always.
Common mistakes¶
- Returning a view of a local container — dangling.
- Taking views as
const auto&— filter views can't be iterated when const. - Iterating an expensive lazy view repeatedly — materialize once.
- Expecting
views::splitto givestd::strings — it yields subranges; convert explicitly. - Mixing up views (lazy, non-owning) and algorithms (eager, may modify).
- Using a view after modifying the underlying container in a way that invalidates iterators.
Exercises¶
- Print the first 20 prime numbers using
views::iota(2) | views::filter(is_prime) | views::take(20). - Given a
std::map<std::string, int>of word counts, produce astd::vector<std::string>of words with count > 5, sorted, using views +std::ranges::to+std::ranges::sort. - Parse a CSV line into fields with
views::split(',')and convert each field tostd::string. - Implement
pairwise_differences(v)withviews::adjacent<2>orviews::pairwise(C++23) — orviews::zip(v, v | views::drop(1))— then with a raw loop. Compare readability. - Demonstrate the "expensive transform called twice" issue by counting calls in
v | views::transform(f) | views::filter(p). - Rewrite the intermediate link checker's line processing (
for_each_prose_line) as astd::generatorof(line_no, line)pairs consumed by views (or write your own view if your compiler lacks<generator>).
Recap¶
- Ranges algorithms take whole ranges and support projections.
- Views are lazy, cheap-to-copy, non-owning ranges; adaptors compose with
|. - Factories (
iota,istream) create ranges; adaptors (filter,transform,take,split,join,zip,enumerate) reshape them. std::ranges::to<Container>()materializes a view (C++23).- Beware dangling views,
constfilter views, and re-evaluation of expensive views. - Custom adaptors: compose existing ones, or return a
std::generator.