C++ Module 11 – Iterators, algorithms and ranges
C++ views and pipelines: filter, transform, iota
Build lazy pipelines with std::views::filter, transform, take and iota, store their results with std::ranges::to, and keep views from outliving their data.
What you will learn
- Compose lazy pipelines with views::filter, views::transform, views::take and views::iota
- Convert a view into a container with std::ranges::to
- Explain when a view outlives its data, and how to prevent it
- Choose between a view and a container for a task
Before you start
What you’ll learn · Before you start
What you will learn
- Compose lazy pipelines with views::filter, views::transform, views::take and views::iota
- Convert a view into a container with std::ranges::to
- Explain when a view outlives its data, and how to prevent it
- Choose between a view and a container for a task
Before you start
On this page
Over the numbers 1 to 8, the pipeline v | std::views::filter(is_even) | std::views::transform(square) | std::views::take(2) tested six numbers, squared two and printed 4 and 16, and building it ran nothing at all. A
view: A range that does not own its elements: it refers to elements stored elsewhere or computes them, is cheap to copy, and does its work only while it is iterated, as std::views::filter and std::views::iota do. is a range that usually owns no elements: it refers to elements stored somewhere else, or
computes them, and it does its work only when you iterate it.
C++ views combine with | into pipelines. Nothing runs until a loop asks for a value, and then the values come one
at a time. std::views::take(n) stops after n values, std::views::iota(1, n + 1) produces the numbers 1 to n
without storing them, and C++23’s std::ranges::to<std::vector>() runs a pipeline and stores what it produces. A view
that refers to its data does not keep it alive, so it must not outlive the container it reads, the one rule that the
compiler cannot always check for you.
A pipeline runs nothing until a loop asks for a value
The figure follows the first value through three views; the program below prints a line at every step:
How a lazy pipeline produces its first value
Text description of the diagram
The diagram follows the first value of the pipeline v | filter(is even) | transform(square) | take(2), from the bottom of the chain to the loop that asks for it.
- The vector v holds 1 to 8. Nothing has been computed when the loop starts.
- The filter reads one element at a time: 1 is not even, so it reads 2, which passes.
- The transform squares 2 and hands on 4.
- take(2) passes 4 on as the first of the two values it will deliver.
- The loop prints "got 4" and asks for the next value, and the same chain runs again from 3.
// Views are lazy: building a pipeline runs nothing, and iterating it pulls one element at a time through every step.
#include <print>
#include <ranges>
#include <vector>
int main() {
std::vector<int> v{1, 2, 3, 4, 5, 6, 7, 8};
auto pipeline = v | std::views::filter([](int x) { std::println(" filter {}", x); return x % 2 == 0; })
| std::views::transform([](int x) { std::println(" square {}", x); return x * x; })
| std::views::take(2);
std::println("pipeline built; nothing has run yet");
for (int y : pipeline) std::println("got {}", y);
std::println("done");
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra lazy_pipeline.cpp -o lazy_pipeline && ./lazy_pipeline
g++-16 -std=c++23 -Wall -Wextra lazy_pipeline.cpp -o lazy_pipeline-gcc && ./lazy_pipeline-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra lazy_pipeline.cpp -o lazy_pipeline && ./lazy_pipeline pipeline built; nothing has run yet filter 1 filter 2 square 2 got 4 filter 3 filter 4 square 4 got 16 filter 5 filter 6 done $ g++-16 -std=c++23 -Wall -Wextra lazy_pipeline.cpp -o lazy_pipeline-gcc && ./lazy_pipeline-gcc pipeline built; nothing has run yet filter 1 filter 2 square 2 got 4 filter 3 filter 4 square 4 got 16 filter 5 filter 6 done
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lazy_pipeline_session.py
Building pipeline printed nothing: the three range adaptor: A function object in std::views, such as filter, transform or take, that takes a range and returns a view of it. Adaptors combine with | into a pipeline: v | std::views::filter(is_even) | std::views::transform(square). only stored the functions they
were given and a reference to v. The loop then pulled the values one at a time. Step through the run, one request
or one value at a time:
The drawing shows the whole scene; its 9 steps are listed below.
Every step as text
- Built, and nothing ran. Building pipeline stored the two lambdas, the 2 of take and a reference to v. The program printed pipeline built; nothing has run yet, and not one element had been read.
- The loop asks. The for loop starts by asking take for its first value. take has none of its own, so it passes the request down the chain.
- Down to the filter. take asks transform, and transform asks filter, because every view only computes from the one below it.
- filter reads 1, then 2. The filter reads 1 from v and rejects it (filter 1), reads 2 and keeps it (filter 2). It stops there: one value is all that was asked for.
- 2 is squared. The filter hands 2 up, and the transform squares it (square 2): 4.
- got 4. take passes 4 on as the first of its two values, and the loop prints got 4. Only now has anything reached the loop.
- The second value. The loop asks again. The filter reads 3 and rejects it, reads 4 and keeps it; the transform squares 4, and the loop prints got 16.
- One more step forward. After got 16 the loop moves its iterator on once more, and that makes the filter look for the next even number: it reads 5 and 6 (filter 5, filter 6). No square 6 follows, because nobody reads the value.
- take says done. take has delivered its two values, so the position after them is its end, and the loop stops: done. 7 and 8 were never read, and no intermediate vector existed at any point.
For the first value, the filter read 1, rejected it, read 2 and passed it on; the transform squared it; and only then
did the loop print 4. After the second value, the loop still incremented its iterator once more, and that increment
made the filter look for the next even number, so it tested 5 and 6 before take reported that it had delivered its
two values. Elements 7 and 8 were never read, and no intermediate vector existed at any point. That is what
lazy evaluation: Doing work only when its result is asked for. A view computes an element when a loop or an algorithm asks for the next one, so building a pipeline runs nothing, and elements that nobody asks for are never computed. means for C++ views: each step does the least work that the next one asks for.
The | is only notation. std::views::filter(v, is_even) is the same view as v | std::views::filter(is_even); the
pipe reads better when several steps follow one another. A pipeline without its data is an object too:
auto evens_squared = std::views::filter(is_even) | std::views::transform(square); can be applied later to any range,
as v | evens_squared, which lets you name a sequence of steps once and reuse it.
Checkpoint
Check: How a lazy pipeline runs
iota counts without a container, and ranges::to stores the results
Numbers from iota, filtered, squared and stored:
// A pipeline over numbers that iota generates, turned into a vector with std::ranges::to (C++23).
#include <print>
#include <ranges>
#include <vector>
int main() {
// iota(1, 11) counts 1, 2, ..., 10 without storing them: the loop counter as a range.
auto even_squares = std::views::iota(1, 11)
| std::views::filter([](int x) { return x % 2 == 0; })
| std::views::transform([](int x) { return x * x; });
std::vector<int> stored = even_squares | std::ranges::to<std::vector>();
std::println("even squares up to 10: {}", stored);
// A view is cheap to keep and can be iterated again; each time, it computes the values afresh.
int sum = 0;
for (int x : even_squares) sum += x;
std::println("their sum: {}", sum);
// An unbounded iota with take: the first five multiples of 7.
auto sevens = std::views::iota(1) | std::views::transform([](int x) { return 7 * x; }) | std::views::take(5);
std::println("multiples of 7: {}", sevens | std::ranges::to<std::vector>());
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra even_squares.cpp -o even_squares && ./even_squares
g++-16 -std=c++23 -Wall -Wextra even_squares.cpp -o even_squares-gcc && ./even_squares-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra even_squares.cpp -o even_squares && ./even_squares even squares up to 10: [4, 16, 36, 64, 100] their sum: 220 multiples of 7: [7, 14, 21, 28, 35] $ g++-16 -std=c++23 -Wall -Wextra even_squares.cpp -o even_squares-gcc && ./even_squares-gcc even squares up to 10: [4, 16, 36, 64, 100] their sum: 220 multiples of 7: [7, 14, 21, 28, 35]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 even_squares_session.py
std::views::iota(1, 11) is the half-open range from 1 to 10, the counter of for (int i = 1; i < 11; ++i) as a
range. std::views::iota(1) has no end and counts for as long as anyone asks, which take(5) limits to five values.
std::ranges::to<std::vector>() (C++23, paper P1206R7) runs a pipeline to its end and builds a vector, deducing the
element type, and it worked with both standard libraries. The pipeline even_squares was iterated twice, and each
pass computed the values again: a view stores the recipe, not the results. Store the results in a container when
you need them more than once and the computation is not cheap.
Other adaptors of C++20 follow the same pattern. std::views::reverse walks a range backwards, std::views::drop(n)
skips the first n elements, std::views::take_while(pred) stops at the first element that fails a test, and
std::views::keys and std::views::values pick the two halves of the entries of a map.
Checkpoint
Check: iota and ranges::to
Keep a view for one pass and a container for results you keep
Three questions about the result decide between them:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Keep | Owns its elements | Cost to create | When the work happens | May outlive its source | Read several times |
|---|---|---|---|---|---|
| A view | Mixed usually not: it refers to them or computes them | Good constant: a few functions and references | Mixed each time it is iterated | Poor no, unless it owns the source | Poor computes the values again |
| A container | Good yes | Poor an allocation and a copy of every element | Good once, while it is filled | Good yes | Good reads the stored values |
How the answers decide
- “Will the result be returned from a function, stored, or shared between threads?” Yes, it is kept: adds a caveat to A view. A view refers to its source, so a view of a local container dangles once the function returns; return a container unless the view owns its source.
- “Will the result be read many times, with steps that cost something?” Yes, several costly passes: adds a caveat to A view. A view stores the recipe, not the results, so every pass computes the values again; store them once.
- “Is it read once, in the place where its source data lives?” Yes, one pass, here: adds a caveat to A container. Filling a container allocates and copies every element, work that a single pass over the source does not need.
Pipelines shine when the data is read once, in one place, while the container it comes from is alive: reports, searches and conversions written as a sentence of steps instead of a loop with temporary vectors. A container is the better choice for results that are kept, returned from a function, shared between threads or read many times.
Views are cheap because they skip the bookkeeping that containers do, and each skipped step has a rule:
- A view that outlives its data. A view returned from a function must not refer to a local container, which is destroyed when the function returns: it would be a dangling view: A std::string_view, or another view that owns nothing, whose characters have been destroyed or freed. Reading it is undefined behaviour, which AddressSanitizer reports as a heap-use-after-free when the characters were on the heap. More in “std::string and std::string_view”.
- A remembered first match. A filter view finds its first match once and remembers it, so it cannot be iterated through a const reference.
- Double work. A transform placed before a filter runs twice for every element that passes the filter. C++26’s
std::views::cache_latest, in GCC 16 but not yet in Apple clang 21, keeps the last value, so the transform runs once. - Two passes in ranges::to. Building a vector from a filter view walks the pipeline twice, once to count and once to copy.
Checkpoint
Check: A view or a container
Practise: lazy pipelines
The first question comes from the idea your answers show is weakest, and every choice explains what it gets right or wrong.
Practice that adapts to you
Practise this lesson
8 questions about 8 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Views that outlive their data, remembered matches and double work, reproduced Pro: opens with a Pro, Premium or Ultimate pass
Each rule of the previous list comes from a failure that compiles and often appears to work. This part reproduces each one on both standard libraries, under AddressSanitizer where memory is involved, counts the extra work, and shows the fix that holds.
In this part:
- A view of a destroyed vector, caught by AddressSanitizer
- Why a filter view cannot be iterated through const, and what its memory misses
- Counted: a transform before a filter runs twice
- C++26 preview: views::cache_latest, already in GCC 16
8 runnable examples (C++, Python) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Pipelines in practice: ranges::to, measured costs and a report to build Pro: opens with a Pro, Premium or Ultimate pass
A pipeline is usually the start of a result that someone keeps, so the step into a container and the cost of the whole chain matter as much as the views themselves. This part measures both on the two libraries, sets out when to keep a view and when to keep a container, and ends with a report to build from views.
In this part:
- What ranges::to does with sized and filtered views
- Measured: a pipeline against a hand-written loop, optimised and not
- When to keep a view and when to keep a container
- Your turn: a report built from views
9 runnable examples (C header, C++, Python) · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher · Warm-up
What does it mean that std::views::filter is lazy, and when does its predicate run?
Lazy means that creating the view computes nothing: v | std::views::filter(pred) only stores pred and a reference to v. The predicate runs while the view is iterated, one element at a time, whenever a loop or an algorithm asks for the next element, so a view that is never iterated never calls it. The work happens again each time the view is iterated, using the data as it is at that moment. In a chain that ends with take(n), laziness also means that reading stops soon after n values have been produced, so the rest of the data is never touched. A good answer adds the consequence for lifetimes: since the view only refers to v, v must still exist whenever the view is iterated.
-
Mid to senior
A function returns auto, computed as items | std::views::filter(is_active), where items is a local std::vector. The tests pass on one machine and crash on another, and AddressSanitizer reports stack-use-after-return. Explain, and give three fixes with their trade-offs.
-
Mid to senior
A log pipeline, lines | views::transform(parse_record) | views::filter(is_error), is slower than expected, and a profiler shows parse_record running about twice for every error line. Explain why, and how you would restructure it.
-
Senior to staff
A colleague proposes a class that stores a std::ranges::filter_view of one of its own std::vector members and returns it from a const member function. What problems do you expect, and what design would you suggest instead?
Key takeaways
- A view refers to data or computes it, and does its work only while it is iterated, one element at a time.
|builds pipelines from adaptors such asviews::filter,views::transform,views::takeandviews::iota.std::ranges::to<std::vector>()(C++23) runs a pipeline and stores the results.- A view must not outlive the container it refers to; return containers, not views of locals.
Terms in this lesson
- lazy evaluation
- Doing work only when its result is asked for. A view computes an element when a loop or an algorithm asks for the next one, so building a pipeline runs nothing, and elements that nobody asks for are never computed.
- range adaptor
- A function object in std::views, such as filter, transform or take, that takes a range and returns a view of it. Adaptors combine with | into a pipeline: v | std::views::filter(is_even) | std::views::transform(square).
- view
- A range that does not own its elements: it refers to elements stored elsewhere or computes them, is cheap to copy, and does its work only while it is iterated, as std::views::filter and std::views::iota do.
References
- Working Draft, Programming Languages: C++ (N4950) (ISO/IEC JTC1/SC22/WG21)
- P0896R4: The One Ranges Proposal (ISO/IEC JTC1/SC22/WG21)
- P1206R7: Conversions from ranges to containers (ISO/IEC JTC1/SC22/WG21)
- P2415R2: What is a view? (ISO/IEC JTC1/SC22/WG21)
- P3138R5: views::cache_latest (ISO/IEC JTC1/SC22/WG21)
- AddressSanitizer (Clang documentation) (LLVM Project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress