C++ / ITERATORS, ALGORITHMS, AND RANGES
Ranges, views, and lazy pipelines
Build lazy pipelines with views::filter, transform and take, reason about when each element is touched, and know when to materialize a view.
What you will learn
- Compose views with | and know that no element is read until the pipeline is consumed
- Read a pipeline as nested iterators: transform acts on *it, filter acts on ++it
- Copy a view into a vector when you need size(), indexing, sorting, or a second pass
- Spot the lifetime trap: a view over a named local holds a reference, not a copy
Understanding Ranges, views, and lazy pipelines
A range is anything std::ranges::begin and std::ranges::end work on, which is why the std::ranges:: algorithms take the container as a single argument. A view is a range that refers to elements it does not own and that can be copied in constant time; std::views::filter, transform, take, drop and reverse are factories that wrap an existing range in a new view type. The pipe is only a spelling convenience: v | std::views::filter(p) | std::views::transform(f) constructs a transform_view wrapping a filter_view wrapping a ref_view, an object holding nothing but a pointer to v, copies of the two callables, and a slot for caching where filtering starts. Constructing it reads no elements at all.
The actual work lives in the iterators of those wrapper types. Dereferencing a transform_view iterator calls f on the current underlying element and returns the result by value; incrementing a filter_view iterator runs a find_if forward through the source until the predicate holds again. Consumption pulls from the far end of the chain: the range-for asks take_view for an element, take_view asks transform_view, which asks filter_view, which finally advances a vector iterator. Nothing is buffered between stages, so the chain makes a single pass and touches only the elements you actually consume, which is why a pipeline ending in take(5) can sit on top of an unbounded views::iota.
Two consequences follow from a view storing a reference and no results. First, the pipeline is valid only while its source lives and its iterators stay valid, so a view built over a local container and returned from a function dangles exactly like a returned pointer. Second, iterating the same pipeline twice re-runs every predicate and every transform, because nothing was memoized, and the adaptors weaken what the range can promise: a filtered view has no size() and no [i], and cannot be passed to ranges::sort. When you need any of that, end the pipeline by copying it into a real container.
<iostream>
<ranges>
<vector>
int main() {
std::vector<int> v{5, 2, 9, 1, 7, 4, 8};
int tested = 0;
auto scaled_odds = v
| std::views::filter([&tested](int n) { ++tested; return n % 2 == 1; })
| std::views::transform([](int n) { return n * 10; });
std::cout << "tested after building the pipeline: " << tested << '\n';
for (int n : scaled_odds | std::views::take(2))
std::cout << "got " << n << '\n';
// 4, not 7: the loop wanted two values, and the last ++ had already
// scanned past 9 to the next odd element before take's counter hit zero.
std::cout << "tested after consuming two values: " << tested << '\n';
}A view is a recipe, not a result: piping adaptors together builds a nested iterator, and each element is computed on demand as the consumer pulls.
Worked examples
A pipeline over an unbounded source
Laziness lets a finite loop run on top of an infinite range.
<iostream>
<ranges>
int main() {
auto squares = std::views::iota(1)
| std::views::transform([](int n) { return n * n; })
| std::views::filter([](int n) { return n % 3 == 1; })
| std::views::take(5);
for (int n : squares)
std::cout << n << '\n';
}Example explained
Line 1std::views::iota(1) has no upper bound; its end is an unreachable sentinel, so only take can stop the traversal.
Line 2The filter keeps squares congruent to 1 mod 3, which is every square of a number not divisible by 3, so 9, 36 and 81 drop out.
Line 3take(5) hands out five values and then compares equal to its sentinel, so iota is never asked for a sixth element.
Line 4Moving take(5) in front of the filter would bound the source to 1..5 first and print only 1, 4, 16, 25: each stage sees only what the previous one emits.
Materializing when a view is not enough
Shows what a view cannot do and how copying it into a vector fixes that.
<algorithm>
<iostream>
<iterator>
<ranges>
<string>
<vector>
int main() {
std::vector<std::string> names{"ada", "grace", "alan", "edsger", "barbara"};
auto initials = names
| std::views::filter([](const std::string& s) { return s.size() > 3; })
| std::views::transform([](const std::string& s) { return s.front(); });
std::vector<char> letters;
std::ranges::copy(initials, std::back_inserter(letters));
std::cout << "view length: " << std::ranges::distance(initials) << '\n';
std::cout << "letters: ";
for (char c : letters) std::cout << c;
std::cout << '\n';
std::ranges::sort(letters); // fine: letters owns its chars
// std::ranges::sort(initials); // ill-formed: not random-access, elements are temporaries
std::cout << "sorted: ";
for (char c : letters) std::cout << c;
std::cout << '\n';
}Example explained
Line 1The ranges::copy line is the only one that stores anything; initials itself holds just a pointer to names plus the two lambdas.
Line 2ranges::distance has to walk the whole pipeline, because a filtered view has no size() and the elements were never counted or kept.
Line 3ranges::sort(letters) works since the vector owns its chars, while sorting initials cannot compile: filter_view is at best bidirectional and transform hands back temporaries, not assignable references.
Line 4The initials come out in source order (grace, alan, edsger, barbara); adaptors skip and map elements but never reorder them.
Important notes
filter_view::begin() is required to cache the first matching position, so mutating the source and then reusing the same view can start a later pass from a stale spot; rebuild the pipeline after modifying the source.
Keep predicates and transforms pure. A stage may be evaluated more than once per element: with filter on top of transform, find_if dereferences the element and your loop body dereferences it again, so the transform runs twice for every element that passes.
Common mistakes
Returning a pipeline built over a local container: v | std::views::filter(p) stores a ref_view to v, so after the function returns every dereference is undefined behaviour. Piping a temporary is the safe case, since the adaptor wraps it in an owning view that takes the container over.
Treating the pipeline as a computed result: calling ranges::distance(pipe) and then looping over it runs the predicate and the transform a second time over the source, doubling both the cost and any side effects.
Reaching for pipe.size() or pipe[0] after a filter stage: filter_view models neither sized_range nor random_access_range, so this fails to compile, and substituting the source container's size gives a count that still includes the rejected elements.
Try it yourself
Change, predict, then run
Using std::views::iota(1), print the first five multiples of 7 whose last digit is 3 (expect 63, 133, 203, 273, 343). Then swap the take and filter stages and explain why the program now prints nothing.
Open the C++ workspaceCheck your understanding
std::views::iota(1) | std::views::filter(is_prime) | std::views::take(5) prints five values and stops, even though iota(1) is unbounded. What makes the traversal finite?
- iota_view stops at a large implementation-defined limit, so any pipeline over it eventually ends.
- filter evaluates its predicate eagerly and gives up once the source produces no new matches.
- Elements are pulled from the consuming end: take asks for one element at a time and reports that it is done after the fifth, so iota is advanced only as far as the fifth prime.
- take copies the first five elements of the pipeline into a small internal buffer before the loop starts.
Show answer
Nothing runs until the range-for asks take_view for an element, and each request travels backwards down the chain to iota, so the source advances only far enough to satisfy five requests before take's counter reaches zero and the loop's sentinel comparison ends it. Option 3 is tempting because take feels like a copy-the-first-N operation, but take_view stores only the wrapped view and a remaining count and never holds elements; option 0 is wrong because an unbounded iota_view ends in unreachable_sentinel, which nothing ever compares equal to.