C++ / a working model

59 / 163   ·   C++20   ·   10 min

Ranges and Lazy views: A Pipeline Is Not a Result Cache

Keep this sentence

C++20 ranges take ranges as algorithm input; views compose lazy operations such as filtering and mapping. Creating a pipeline usually does not compute the full result, and repeated traversal does not promise to reuse results. Understanding underlying ownership, iteration capability, and the final materialization boundary matters more than pipeline syntax.

In this lesson
  1. A range interface and a view are two different things
  2. filter and transform work at consumption time
  3. Materialize explicitly when you need a snapshot
  4. Example
  5. Exercise

A range interface and a view are two different things

A range is a sequence from which a begin and an end can be obtained; the end position may even be expressed by a sentinel of a different type. ranges algorithms accept a whole range and use concepts to constrain the required iteration capabilities. A view is a kind of range type suited to cheap moves and composition; it is not the same as a container, and it is not necessarily the same as owning no data at all.

Some views borrow an existing container, some generate values on demand, and some forms can own the underlying object. Having view in the name cannot by itself answer lifetime questions. The example builds a pipeline from a local vector lvalue, so the pipeline borrows that container; the container lives for the entire traversal and no structure-modifying operations that would invalidate iterators occur.

filter and transform work at consumption time

values | views::filter(pred) | views::transform(fn) describes how to access the result: filtering happens as iteration advances, and transformation happens when the related iterator is dereferenced. It generally does not immediately allocate a result array, nor does it store every transformed result. Therefore you cannot treat the time the view is created as the time the whole algorithm finishes.

Repeated traversal may re-execute the transformation; some views also cache iteration state rather than result values. Prefer giving predicates and transform functions stable behavior that matches the semantic requirements; do not infer a portable exact call count from print counts or global counters. Pipelines with side effects especially need review.

Materialize explicitly when you need a snapshot

The example filters even numbers then squares them, and finally puts them into a vector with one loop. That step explicitly forms an owning snapshot of the result; afterward, even if the original input changes, the result container does not. The C++20 example does not use C++23's ranges::to, to avoid mislabeling a newer library feature as a baseline capability.

Laziness is not always faster: when an expensive transformation is consumed multiple times, materializing once may be cheaper; when only a short prefix is taken, laziness may skip a large amount of unused work. filter may also weaken random-access capability; you cannot assume every container algorithm can act directly on an arbitrary pipeline. When choosing, consider algorithm needs, lifetime, and access count together.

Pitfalls

  • Returning a view that borrows a local vector dangles; the pipeline object being alive does not mean the data it refers to is alive.
  • Do not arbitrarily modify elements while traversing a filter_view so they no longer satisfy the predicate, or perform operations that invalidate underlying iterators; lazy filtering has its own validity requirements.

Run an example

Minimum C++20 · complete program · Download .cpp

#include <cassert>
#include <iostream>
#include <ranges>
#include <vector>

int main() {
    std::vector<int> values{1, 2, 3, 4, 5, 6};
    auto selected = values
        | std::views::filter([](int n) { return n % 2 == 0; })
        | std::views::transform([](int n) { return n * n; });
    std::vector<int> result;
    for (int n : selected) result.push_back(n);
    assert((result == std::vector<int>{4, 16, 36}));
    values[1] = 8;
    assert(result[0] == 4);
    std::cout << result[0] << ' ' << result[1] << ' ' << result[2] << '\n';
}

Compile locally

g++ -std=c++20 -Wall -Wextra -Wpedantic -pthread modern-ranges.cpp -o example && ./example

Expected result

4 16 36

CHECK YOUR UNDERSTANDING

Close the answer. Explain it.

If you want to keep only the first two squares after filtering, where should views::take(2) go? What happens if you put it at the very front of the whole pipeline?

Show a reference answer

Place it after the existing filter and transform; the result is 4, 16. Putting it at the front first takes 1, 2 from the input, then filters even numbers, leaving only 4. Pipeline order expresses business order; take operates on the sequence at the current stage and cannot always be automatically understood as the number of final results.

Check the sources

Drafts and official chapters change. The version mark is only the example’s minimum.

Back to the catalog