A view is a lightweight, lazy description of a sequence: "these elements,
filtered by this, transformed by that". Nothing is computed until someone
iterates. Views compose with | into pipelines, and std::ranges::to
turns a pipeline into a real container at the end. Used well, they replace
most hand-written loops with code that reads like the problem statement.
Used carelessly, they dangle — so this lesson covers both.
1. Views are lazy and cheap
#include <print>
#include <ranges>
#include <vector>
int main() {
std::vector<int> v{1, 2, 3, 4, 5, 6};
int calls = 0;
auto squares = v | std::views::transform([&](int x) { ++calls; return x * x; });
std::println("after building the view: {} calls", calls); // 0 — nothing ran
for (int s : squares | std::views::take(2)) std::print("{} ", s);
std::println("\nafter taking two: {} calls", calls); // 2 — only what was pulled
}v | transform(square) | take(2): the loop pulls two elements, so the lambda runs twice. The other four squares are never computed.Three properties to internalise:
- Lazy. A view computes elements on demand, one at a time, when an iterator is dereferenced.
- Cheap to copy. A view holds a reference (or a small owned range) plus the function objects — copying it never copies elements.
- Lazy, but not minimal. Incrementing a
filteriterator scans ahead to the next match, sofilter(p) | take(2)can callpon elements after the two it keeps. Don't put expensive or side-effecting predicates there. - A view, not a container. It doesn't store results. Iterate twice and the
transformlambda runs twice.
2. Pipelines and ranges::to
r | adaptor is the same as adaptor(r); chains read left to right.
std::ranges::to<C>() (C++23) materialises the result:
#include <map>
#include <print>
#include <ranges>
#include <string>
#include <vector>
int main() {
std::vector<int> v{5, 8, 2, 9, 4};
auto big_doubled = v | std::views::filter([](int x) { return x > 3; })
| std::views::transform([](int x) { return 2 * x; })
| std::ranges::to<std::vector>(); // element type deduced
std::println("{}", big_doubled);
auto by_square = v | std::views::transform([](int x) { return std::pair{x, x * x}; })
| std::ranges::to<std::map>(); // pairs → map
std::println("{}", by_square);
}ranges::to iterates once and builds the vector.3. The C++23/26 adaptors worth knowing
| Adaptor | Gives | Example use |
|---|---|---|
views::enumerate |
(index, element) |
"index of first match" |
views::zip(a, b) |
tuples of parallel elements | pairing two arrays |
views::slide(k) |
every window of k neighbours | moving averages |
views::adjacent_transform<2>(f) |
f(x[i], x[i+1]) |
differences |
views::chunk(k) |
consecutive blocks of k | batching |
views::chunk_by(pred) |
runs where pred(prev, next) holds |
run-length encoding |
views::stride(k) |
every k-th element | sampling |
views::join_with(sep) |
flatten with a separator | joining words |
views::cartesian_product(a, b) |
all pairs | grids |
views::concat(a, b) (C++26) |
a then b | merging sources |
#include <print>
#include <ranges>
#include <string>
#include <vector>
int main() {
std::vector<int> xs{1, 1, 4, 4, 4, 2};
std::println("{}", xs | std::views::adjacent_transform<2>([](int a, int b) { return b - a; })
| std::ranges::to<std::vector>()); // [0, 3, 0, 0, -2]
std::println("{}", xs | std::views::chunk_by(std::equal_to{})
| std::views::transform([](auto run) { return std::ranges::distance(run); })
| std::ranges::to<std::vector>()); // [2, 3, 1]
std::vector<std::string> words{"views", "are", "lazy"};
std::println("{}", words | std::views::join_with(' ') | std::ranges::to<std::string>());
std::println("{}", std::views::concat(xs, std::vector{7, 8}) | std::ranges::to<std::vector>());
}chunk_by(equal_to{}) cuts where neighbours differ: runs of 2, 3 and 1. adjacent_transform<2> looks at each neighbouring pair: n elements give n − 1 results.4. Folds
std::ranges::fold_left(r, init, op) (C++23) is the ranges accumulate. The
init value decides the accumulator type — fold_left(v, 0, std::plus{})
sums in int even if that overflows:
#include <algorithm>
#include <climits>
#include <functional>
#include <print>
#include <vector>
int main() {
std::vector<int> big{INT_MAX, INT_MAX};
std::println("{}", std::ranges::fold_left(big, 0LL, std::plus{})); // 4294967294
// fold_left(big, 0, std::plus{}) would overflow int: undefined behaviour.
}fold_left_first, fold_right and friends exist too; fold_left_first
returns an optional because an empty range has no first element.
5. Lifetime: who owns the elements?
A view over an lvalue container is a ref_view: it stores a pointer to
the container. A view over an rvalue (a temporary) is an owning_view
(C++23): it moves the temporary in and keeps it alive. So this is fine:
#include <print>
#include <ranges>
#include <vector>
int main() {
for (int x : std::vector{1, 2, 3, 4} | std::views::filter([](int x) { return x % 2 == 0; }))
std::print("{} ", x); // the temporary vector is owned by the view
std::println();
}But a view built inside a function over a reference parameter stores a
ref_view — if the caller passed a temporary, the view dangles as soon as
the call's full-expression ends:
auto evens_of(const std::vector<int>& v) {
return v | std::views::filter([](int x) { return x % 2 == 0; }); // ref_view to v
}
auto e = evens_of(std::vector{1, 2, 3, 4}); // the vector dies here
for (int x : e) { /* reads a dead vector: undefined behaviour */ }evens_of is called with a temporary, the vector dies at the end of the full-expression and e points at freed memory. (owning_view only happens when the view itself is built from an rvalue.)Rules of thumb: return containers (ranges::to) from functions unless the
caller clearly owns the source; never build string_views into temporaries;
and take views by value, not by reference.
6. Caching and const: two surprises
Some views do work in begin() — filter must scan to the first matching
element, drop_while to the first non-dropped one. To keep begin() O(1)
amortised they cache the result after the first call:
#include <print>
#include <ranges>
#include <vector>
int main() {
std::vector<int> u{1, 2, 3, 4};
auto evens = u | std::views::filter([](int x) { return x % 2 == 0; });
std::println("{}", *evens.begin()); // 2 — and begin() is now cached
u[0] = 10; // the first element is now even…
std::println("{}", *evens.begin()); // …but this still prints 2
}begin() scans to the first even element and remembers it. Changing u[0] afterwards doesn't move the cached position.Consequences:
- Don't reuse a caching view after modifying the underlying range — make a new view. Modifying elements through a filter view so they no longer satisfy the predicate is undefined behaviour.
- Because
begin()mutates the cache,filter_viewanddrop_while_vieware not const-iterable: aconstfilter view (or aconst¶meter of filter-view type) can't be iterated. Take views by value.
7. Worked example: a log summary pipeline
Count error lines per service from text like "auth ERROR timeout":
#include <algorithm>
#include <map>
#include <print>
#include <ranges>
#include <string>
#include <string_view>
int main() {
std::string_view log = "auth ERROR timeout\ndb INFO ok\nauth ERROR denied\ndb ERROR lock\n";
auto errors = log | std::views::split('\n')
| std::views::transform([](auto line) { return std::string_view(line); })
| std::views::filter([](std::string_view l) { return l.contains(" ERROR "); })
| std::views::transform([](std::string_view l) { return l.substr(0, l.find(' ')); });
std::map<std::string, int> per_service;
for (std::string_view service : errors) ++per_service[std::string(service)];
std::println("{}", per_service); // {"auth": 2, "db": 1}
}Read it top to bottom: lines → as string_views → only errors → service name.
The string_views point into log, which outlives the pipeline, so they're
safe; the map stores owning std::string keys.
Faded example — your turn
Produce the three longest distinct words of a sentence, longest first (ties alphabetically). Fill in the pipeline stages:
auto words = text | std::views::split(' ')
| /* ??? to std::string */
| std::ranges::to<std::vector>();
std::ranges::sort(words, /* ??? */);
auto [first, last] = std::ranges::unique(words);
words.erase(first, last);
auto top3 = words | std::views::take(3) | std::ranges::to<std::vector>();Answer
std::views::transform([](auto w) { return std::string(std::string_view(w)); });
comparator: [](const auto& a, const auto& b) { return std::tuple(b.size(), a) < std::tuple(a.size(), b); }
— longer first, then alphabetical; equal words end up adjacent so unique removes duplicates.
8. std::generator: lazy sequences you write yourself
A C++23 std::generator<T> is a coroutine that co_yields values on demand.
It's an input range, so it pipes like a view, and it can be infinite:
#include <generator>
#include <print>
#include <ranges>
#include <utility>
#include <vector>
std::generator<long long> fibonacci() {
long long a = 0, b = 1;
while (true) {
co_yield a;
a = std::exchange(b, a + b);
}
}
int main() {
auto evens = fibonacci() | std::views::filter([](long long x) { return x % 2 == 0; })
| std::views::take(5)
| std::ranges::to<std::vector>();
std::println("{}", evens); // [0, 2, 8, 34, 144]
}The coroutine's locals live in its frame between co_yields; the consumer
decides how much to pull. A generator can only be iterated once.
9. Writing your own adaptor
Deriving from std::ranges::range_adaptor_closure<Self> (C++23) makes a
callable object pipeable and composable:
#include <print>
#include <ranges>
#include <vector>
struct Squared : std::ranges::range_adaptor_closure<Squared> {
template <std::ranges::viewable_range R>
auto operator()(R&& r) const {
return std::views::transform(std::forward<R>(r), [](auto x) { return x * x; });
}
};
inline constexpr Squared squared{};
int main() {
std::vector v{1, 2, 3, 4};
std::println("{}", v | squared | std::ranges::to<std::vector>());
auto first_two_squared = squared | std::views::take(2); // closures compose
std::println("{}", v | first_two_squared | std::ranges::to<std::vector>());
}For an adaptor with arguments, store them as members and return the closure
from a factory function (clamp_to(lo, hi) in the build task).
Summary
- Views are lazy, cheap to copy, and compute on every iteration.
- Compose with
|; materialise once withstd::ranges::to<C>(). - Learn the C++23 toolbox:
enumerate,zip,slide,adjacent_transform,chunk_by,stride,join_with, and C++26concat. fold_left's init value sets the accumulator type — use0LLfor big sums.- Views over temporaries passed by reference dangle; return containers.
filter/drop_whilecachebegin()and aren't const-iterable.std::generatorwrites lazy sequences;range_adaptor_closuremakes your own adaptors pipe.