C++20 Views: How Lazy Pipelines Evaluate, and the Lifetime and Caching Traps

Key takeaways

A C++20 view pipeline does no work when you build it; each element is pulled through the whole chain only when you iterate. That model explains both the benefits (no temporary containers, early stopping) and the traps: dangling references, repeated work, and filter views that cannot be iterated as const.

What are views?

Views are lazily evaluated ranges (C++20).

#include <iostream>
#include <ranges>
#include <vector>
std::vector<int> v = {1, 2, 3, 4, 5};
// View: no copy
auto filtered = v | std::views::filter([](int x) { return x > 2; });
// Evaluated on iteration
for (int x : filtered) {
    std::cout << x << " ";  // 3 4 5
}

The line that creates filtered does no filtering. It builds a small object that remembers two things: a reference to v and the predicate. Only when the for loop asks for the first element does the view walk v, test elements, and hand back the first match. Then it stops and waits for the next request. This is pull-based, element-by-element evaluation. Each element travels through the entire pipeline before the next one starts.

That model is worth keeping in your head, because every benefit and every trap in this article follows from it. The benefits: no temporary vectors between steps, work that stops as soon as you stop iterating, and pipelines over sequences that would not fit in memory (or are infinite, like std::views::iota(1)). The traps: a view does not own its data, so it can dangle. It does not store results, so every pass recomputes. And some views keep a little internal state, which has consequences for const.

filter, transform, take, drop, and reverse

namespace vws = std::views;
// filter: keep elements matching a predicate
vws::filter([](int x) { return x % 2 == 0; })
// transform: map each element to a new value
vws::transform([](int x) { return x * 2; })
// take: first n elements
vws::take(5)
// drop: skip first n
vws::drop(3)
// reverse
vws::reverse

These are range adaptors: objects that take a range and return a view. The | syntax is just function application written left to right: v | vws::take(5) means vws::take(v, 5). See C++ range adaptors for how adaptor objects compose on their own before being applied to a range.

Building pipelines step by step

filter

#include <iostream>
#include <ranges>
#include <vector>
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// Evens only
auto evens = numbers | std::views::filter([](int x) { 
    return x % 2 == 0; 
});
for (int x : evens) {
    std::cout << x << " ";  // 2 4 6 8 10
}

transform

std::vector<int> numbers = {1, 2, 3, 4, 5};
// Squares
auto squares = numbers | std::views::transform([](int x) { 
    return x * x; 
});
for (int x : squares) {
    std::cout << x << " ";  // 1 4 9 16 25
}

A transform view computes its value every time an element is accessed. It does not store the squares anywhere. For a cheap lambda like x * x that costs nothing, but it matters for expensive functions and for functions with side effects, as the section on adapter order below shows.

take and drop

std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// First 5
auto first5 = numbers | std::views::take(5);
// 1 2 3 4 5
// Skip first 3
auto skip3 = numbers | std::views::drop(3);
// 4 5 6 7 8 9 10
// Combined: skip 3, then take 5
auto middle = numbers | std::views::drop(3) | std::views::take(5);
// 4 5 6 7 8

Unlike indexing, take(n) and drop(n) are safe when n is larger than the range: take(100) on 10 elements yields 10, and drop(100) yields an empty view. That makes them convenient for pagination-style code where the last page is short.

A composite pipeline that stops early

#include <cctype>
#include <iostream>
#include <ranges>
#include <string>
#include <vector>
struct Person {
    std::string name;
    int age;
};
std::vector<Person> people = {
    {"Alice", 25},
    {"Bob", 30},
    {"Charlie", 35},
    {"David", 40}
};
// Age >= 30 -> names -> uppercase -> first 2
auto result = people
    | std::views::filter([](const Person& p) { return p.age >= 30; })
    | std::views::transform([](const Person& p) { return p.name; })
    | std::views::transform([](std::string name) {
        for (char& c : name) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
        return name;
    })
    | std::views::take(2);
for (const auto& name : result) {
    std::cout << name << std::endl;  // BOB, CHARLIE
}

Because evaluation is per element and take(2) stops the iteration, “David” is never tested, copied, or uppercased. Two smaller details: the first transform returns p.name by value, so each access copies the string (return const std::string& from the lambda, with an explicit return type, if you only need to read it). And std::toupper has undefined behavior for negative char values, which non-ASCII bytes are on most platforms, hence the cast to unsigned char.

take_while and drop_while

namespace vws = std::views;
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// take_while: while predicate holds
auto lessThan5 = v | vws::take_while([](int x) { return x < 5; });
// 1 2 3 4
// drop_while: skip while predicate holds
auto from5 = v | vws::drop_while([](int x) { return x < 5; });
// 5 6 7 8 9 10

take_while stops at the first element that fails the predicate, unlike filter, which skips failures and keeps going. On {1, 7, 2}, take_while(x < 5) yields only 1, while filter(x < 5) yields 1 2. take_while is the one that works on infinite ranges, for example views::iota(1) | views::take_while(...).

iota, keys and values

Two more views replace common hand-written loops. views::iota(a, b) generates a, a+1, ..., b-1 without any container, and views::keys / views::values pick the first or second member of each pair, which is convenient for maps:

std::map<std::string, int> stock{{"apple", 3}, {"pear", 0}, {"plum", 7}};
for (const auto& name : stock
                      | std::views::filter([](const auto& kv) { return kv.second > 0; })
                      | std::views::keys) {
    std::cout << name << ' ';   // apple plum
}

for (int i : std::views::iota(0, 5)) std::cout << i;   // 01234

iota has a type trap that almost everyone hits once. std::views::iota(0, v.size()) does not compile in C++20, because 0 is an int and v.size() is a std::size_t, and iota requires both bounds to be comparable without mixing signedness. The error is long and mentions unsatisfied constraints on iota_view. Write std::views::iota(std::size_t{0}, v.size()), or, if what you really want is indices of a container, C++23’s std::views::enumerate(v), which yields index and element pairs. A single-argument iota(1) is unbounded, so it must be followed by something that stops it, such as take or take_while, or the loop never ends.

Dangling views, mutation, re-evaluation, and ordering

Views that outlive their container

// ❌ Dangling view
auto getView() {
    std::vector<int> v = {1, 2, 3};
    return v | std::views::filter([](int x) { return x > 1; });
    // v destroyed, the returned view refers to it
}
// ✅ Own the result
auto getFiltered() {
    std::vector<int> v = {1, 2, 3};
    std::vector<int> result;
    auto view = v | std::views::filter([](int x) { return x > 1; });
    std::ranges::copy(view, std::back_inserter(result));
    return result;
}

When you pipe an lvalue container (a named variable), the view stores a reference to it. Returning that view from a function compiles without any warning and reads freed memory later. This is the same class of bug as returning a std::string_view to a local string. Since the C++20 defect fix P2415 (implemented in current compilers), piping an rvalue container, such as std::vector{1, 2, 3} | views::filter(...) or std::move(v) | ..., makes the view take ownership of it, which is safe to return. In C++23, std::ranges::to<std::vector>() replaces the manual copy into result.

In my experience, this is the view bug that survives code review most often, because the lifetime problem is not visible at the call site. A function that returns auto hides the fact that its return type is a view holding a reference, and the caller reasonably assumes it received a value. My rule is that functions that return views should take the underlying range as a parameter (so the caller owns it), or return a container. Views work best as local, short-lived pipelines.

Mutating the container under a view

// Views do not copy the sequence
std::vector<int> v = {1, 2, 3};
auto view = v | std::views::transform([](int x) { return x * 2; });
v[0] = 10;
// view still refers to v
for (int x : view) {
    std::cout << x << " ";  // 20 4 6
}

Changing element values is visible through the view, as shown. Changing the container’s structure is more dangerous: a push_back that reallocates v invalidates iterators the view may have stored. filter_view caches the position of its first match after the first call to begin() (so repeated begin() calls are cheap). After a reallocation, that cache points to freed memory. As a rule, do not modify a container’s size while views over it are still in use.

A related rule applies to filter: modifying an element through a filter view so that it no longer matches the predicate is undefined behavior. The view assumes that every element it has found keeps satisfying the predicate.

Re-evaluation and const views

auto view = v | std::views::filter(pred) | std::views::transform(func);
// Recomputed each pass
for (int x : view) { /* ... */ }  // compute
for (int x : view) { /* ... */ }  // compute again
// ✅ Cache if needed
std::vector<int> cached(view.begin(), view.end());

Every pass over a view runs every predicate and transform again. If func is expensive and the result is used several times, materialize it into a container once.

The caching in filter_view mentioned above has another surprising consequence: begin() is not a const member function for filter_view (and some other views), because it may update the cache. So you cannot iterate a const filter view. A function taking const auto& view and looping over it fails to compile with a long error message. Pass views by value (they are cheap to copy) or by forwarding reference (auto&&) instead of const&.

Adapter order changes results and work

std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
auto isEven = [](int x) { return x % 2 == 0; };

// Order changes the RESULT
auto a = v | std::views::take(3) | std::views::filter(isEven);  // 2
auto b = v | std::views::filter(isEven) | std::views::take(3);  // 2 4 6

// transform before take: still only 3 squares computed (lazy)
auto r1 = v
    | std::views::transform([](int x) { return x * x; })
    | std::views::take(3);

// transform before filter: func runs TWICE for every element that passes
int calls = 0;
auto r2 = v
    | std::views::transform([&](int x) { ++calls; return x * x; })
    | std::views::filter(isEven);
for (int x : r2) {}
// calls == 15: 10 for the predicate checks, plus 5 again when reading the matches

The first pair shows that adapter order is part of the meaning, not only performance. “First three, then the even ones” and “the even ones, then the first three” are different questions.

The transform then take case is often described as wasteful, but because evaluation is lazy, it computes only three squares, exactly like the reverse order. The real performance trap is transform before filter. The filter reads each element to test the predicate, which calls the transform function. Then the loop reads each matching element again, which calls the transform function again. The standard library does not cache transformed values. For a cheap arithmetic lambda this is irrelevant. For a lambda that parses a string, allocates, performs I/O, or increments a counter, you get double the work or double the side effects. The fixes are to filter first when possible, or to materialize the transformed values into a container before filtering.

Chaining filter, transform, reverse, and take

namespace vws = std::views;
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// Multiple adapters
auto result = v
    | vws::filter([](int x) { return x % 2 == 0; })  // evens
    | vws::transform([](int x) { return x * x; })    // squares
    | vws::reverse                                    // reverse
    | vws::take(3);                                   // first 3
// 100 64 36

reverse needs to walk backward, so it requires a bidirectional range. It works here because vector iterators are bidirectional and filter and transform keep that property. reverse over a view of an input stream, or over a forward-only range such as std::forward_list, fails to compile. When a long pipeline produces an unreadable compile error, removing adapters from the end one at a time usually reveals which one has the stricter requirement.

FAQ

Q1: What are views?

A: Lightweight, non-owning (for lvalue sources) range objects that compute elements on demand.

Q2: When does evaluation happen?

A: Element by element, when you iterate or pass the view to an algorithm.

Q3: Do views copy elements?

A: No, they refer to the source. That is why a view must not outlive the container it refers to.

Q4: How do I compose them?

A: With the | operator, left to right. The order affects both the result and the amount of work.

Q5: How is performance?

A: Usually close to a hand-written loop for simple pipelines. Watch for transform-before-filter, repeated passes, and returning copies from transform lambdas.