CodeRigor

FREE LESSON

Ranges: views and pipelines

Compose lazy views with |, materialise with ranges::to, fold, generate with std::generator, and know where views dangle or cache.

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

C++
#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
}
A view computes only what is pulled
start / 4
v | transform(square) | take(2): the loop pulls two elements, so the lambda runs twice. The other four squares are never computed.
A view computes only what is 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 filter iterator scans ahead to the next match, so filter(p) | take(2) can call p on 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 transform lambda 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:

C++
#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);
}
A pipeline reads left to right; nothing runs until ranges::to pulls
Each stage is a lazy view over the previous one. ranges::to iterates once and builds the vector.
A pipeline reads left to right; nothing runs until ranges::to pulls
Each stage is a lazy view over the previous one. 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
C++
#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 and adjacent_transform on the same input
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.
chunk_by and adjacent_transform on the same input
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:

C++
#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:

C++
#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:

C++
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 */ }
A ref_view is a pointer; it doesn't keep the vector alive
Over a named vector, the view's pointer is fine. When 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.)
A ref_view is a pointer; it doesn't keep the vector alive
Over a named vector, the view's pointer is fine. When 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:

C++
#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
}
filter_view caches begin()
start / 2
The first begin() scans to the first even element and remembers it. Changing u[0] afterwards doesn't move the cached position.
filter_view caches begin()
The first 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_view and drop_while_view are not const-iterable: a const filter view (or a const& parameter 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":

C++
#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:

C++
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:

C++
#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:

C++
#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 with std::ranges::to<C>().
  • Learn the C++23 toolbox: enumerate, zip, slide, adjacent_transform, chunk_by, stride, join_with, and C++26 concat.
  • fold_left's init value sets the accumulator type — use 0LL for big sums.
  • Views over temporaries passed by reference dangle; return containers.
  • filter/drop_while cache begin() and aren't const-iterable.
  • std::generator writes lazy sequences; range_adaptor_closure makes your own adaptors pipe.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Ranges in practice: composing views into pipelines, with common mistakes to avoid.

Writing your own views and understanding view semantics.

Effective use of C++20/23 ranges including ranges::to and newer views.

Standard views in depth — including caching, const-iteration and dangling surprises.

C++23 chunk and stride views.

C++23 views::zip: iterating several ranges in lock-step.

C++23 slide_view vs adjacent_view: sliding windows over a range, runtime vs compile-time window size.

C++23 views::enumerate: index + element without a manual counter.