Skip to content

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() and end(): 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 const filter view: take view parameters by value or auto&&, not const 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.
  • filter followed by transform followed by filter can evaluate the transform function more than once per element in some compositions (e.g. transform | filter calls 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::split to give std::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

  1. Print the first 20 prime numbers using views::iota(2) | views::filter(is_prime) | views::take(20).
  2. Given a std::map<std::string, int> of word counts, produce a std::vector<std::string> of words with count > 5, sorted, using views + std::ranges::to + std::ranges::sort.
  3. Parse a CSV line into fields with views::split(',') and convert each field to std::string.
  4. Implement pairwise_differences(v) with views::adjacent<2> or views::pairwise (C++23) — or views::zip(v, v | views::drop(1)) — then with a raw loop. Compare readability.
  5. Demonstrate the "expensive transform called twice" issue by counting calls in v | views::transform(f) | views::filter(p).
  6. Rewrite the intermediate link checker's line processing (for_each_prose_line) as a std::generator of (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, const filter views, and re-evaluation of expensive views.
  • Custom adaptors: compose existing ones, or return a std::generator.

Next: Libraries, ABI & packaging →