AI InterviewSearch…Search problems, topics, or pages…Ctrl KGet started
Skip to content

14 Algorithms and lambdas / Lesson 5 of 5

Ranges and views — C++20 pipelines

15 min · +10 XP · 2 exercises · 7 questions

Read the text15 min
Exercises0/2
Quiz7 q

Quick answer

What are ranges and views in C++20?

C++20 ranges, in <ranges>, are a new layer over the STL. The constrained algorithms in std::ranges take a whole container, check it with concepts and accept a projection, as in std::ranges::sort(people, {}, &Person::age). Views in std::views, such as filter, transform and take, are lazy, non-owning adaptors composed with |: nothing is computed until the pipeline is iterated.

Every algorithm so far took two iterators, and every pipeline needed an intermediate vector per step. C++20's <ranges> fixes both. The constrained algorithms in std::ranges:: take the container itself and a projection that says which part of each element to look at; the views in std::views:: are lazy, non-owning adaptors that compose with | into a pipeline that computes nothing until it is walked. This lesson settles what each brings, how a pipeline is materialised into a vector on a C++20 runtime, and the handful of rules — caching, const, re-evaluation, lifetime — that keep views honest.

Constrained algorithms and projections

#include <algorithm>
#include <ranges>

std::vector<int> v{5, 3, 8, 1};
std::ranges::sort(v);                                              // whole container, no begin/end
auto it = std::ranges::find(v, 8);                                 // an iterator, or v.end()
auto n  = std::ranges::count_if(v, [](int x) { return x > 2; });

std::ranges::sort(v) is std::sort(v.begin(), v.end()) with the iterators taken for you and, crucially, with concepts (Module 12, lesson 5) on the parameters: passing a std::list fails with a constraint error that names random_access_iterator, not with forty lines of template noise. The iterator-pair forms still exist, and the end may be a sentinel of a different type from the iterator. Return values changed in a few places — std::ranges::minmax(v) returns a struct with .min and .max values, std::ranges::minmax_element the same with iterators, and std::ranges::sort returns the end of the range.

The extra parameter is the projection: a callable applied to each element before the comparator or predicate sees it.

struct Person { std::string name; int age; };
std::vector<Person> people = readPeople();
std::ranges::sort(people, {}, &Person::age);                                     // by age, default less
std::ranges::sort(people, std::ranges::greater{}, &Person::age);                 // oldest first
auto adults = std::ranges::count_if(people, [](int a) { return a >= 18; }, &Person::age);
auto ann    = std::ranges::find(people, std::string{"ann"}, &Person::name);
std::ranges::sort(people, {}, [](const Person& p) { return std::tie(p.age, p.name); });   // age, then name

{} is the default comparator std::ranges::less; a pointer-to-member is the commonest projection, and a lambda returning a std::tie is how to sort by a key with a tie-break. The comparator sees ints, the predicate sees ints, find compares a std::string — each part of the call does one job, where the classic lambda comparator did three.

Views

std::vector<int> v{1, 2, 3, 4, 5, 6, 7, 8};
auto evens    = v | std::views::filter([](int x) { return x % 2 == 0; });      // 2 4 6 8
auto squares  = evens | std::views::transform([](int x) { return x * x; });     // 4 16 36 64
auto firstTwo = squares | std::views::take(2);                                  // 4 16
for (int x : firstTwo) std::cout << x << ' ';

A view is a lightweight object that refers to a range and presents it differently. Nothing is computed when the view is created: filter runs its predicate and transform its function only when an element is asked for, by the for loop or an algorithm — laziness. A view is non-owning (it holds the vector by reference, so the vector must outlive it), cheap to copy, and composed with |: the pipeline v | filter | transform | take reads left to right, with no intermediate containers, and stops after two elements because take never asks for a third.

AdaptorYields
views::filter(pred)the elements for which pred is true
views::transform(f)f(element) for each element
views::take(n) / views::drop(n)the first n / everything after the first n
views::take_while(pred) / views::drop_while(pred)a prefix / the rest, decided by pred
views::reversethe range backwards (bidirectional or better)
views::iota(a, b) / views::iota(a)the integers in [a, b) / from a without end
views::keys / views::valuesthe .first / .second of each pair — a map's keys
for (int i : std::views::iota(1, 6)) std::cout << i;                             // 12345
auto odds = std::views::iota(1) | std::views::filter([](int x) { return x % 2; })
                                | std::views::take(3);                            // 1 3 5, from an infinite range
for (const auto& k : ages | std::views::keys) std::cout << k << ' ';              // a map's keys, in order
for (int x : v | std::views::reverse | std::views::drop(1)) std::cout << x;       // 7654321

views::iota(1) with no upper bound is infinite, and the pipeline still terminates because take(3) stops pulling — the clearest demonstration that a view is a recipe, not a result. std::ranges::for_each, count_if, find, max_element and the rest accept a view wherever they accept a container.

Materialising a view

A view is not a vector: it has no size() unless every adaptor preserves one (filter does not), cannot be indexed unless random access survives, and cannot be returned from a function that lets the source die. To keep the results, copy them out:

#include <iterator>

std::vector<long long> out;
std::ranges::copy(v | std::views::filter(positive) | std::views::transform(square) | std::views::take(k),
                  std::back_inserter(out));

std::ranges::copy into a std::back_inserter is the C++20 answer. The iterator-pair constructor std::vector<int>(r.begin(), r.end()) does not compile for a pipeline like this: filter and take produce an end() that is a sentinel of a different type from begin(), and the constructor wants two of the same. r | std::views::common bridges the two when an old interface insists on an iterator pair. C++23 adds std::ranges::to<std::vector>() at the end of a pipeline, plus std::views::enumerate, zip, chunk and adjacent — worth recognising in modern code and in interviews, but this track's runtime is C++20 and none of them compile here: ranges::copy and back_inserter are the tools.

What to keep in mind

filter caches begin(). Finding the first passing element costs a scan, so filter_view remembers where it is. Two consequences: the view's begin() is not const, so a const auto evens = v | std::views::filter(…) cannot be iterated (compile error), and changing the underlying vector after the first pass leaves the cache stale. Build the view, walk it, and do not mutate the source in between.

transform recomputes. The function runs every time an element is read — walk a transform view twice and pay twice; index it in a loop and pay per access. Materialise when the function is expensive or the result is read more than once.

Views dangle like references. A view built from an lvalue holds a reference to it: return v | std::views::filter(…) from the function that owns v, or keep the view in a member after the vector is gone, and the view refers to a dead object — a bug of exactly Module 6's kind. (A pipeline started from an rvalue container, getVector() | std::views::filter(…), moves the container into an owning_view and is safe.) When you must return the data, return a vector.

Adaptors lose capabilities. filter drops random access and size(); reverse needs a bidirectional source; transform keeps the category but, when its function returns by value, yields temporaries you cannot assign through. When an algorithm refuses a view, the concept named in the error is the missing capability.

What ranges fix

The classic std::sort(v.begin(), v.end(), [](const Person& a, const Person& b) { return a.age < b.age; }) becomes std::ranges::sort(people, {}, &Person::age); the filter–map–reduce pipeline of lesson 3, with its two intermediate vectors, becomes one expression with none; "the first k elements that match" stops being a loop with a counter; and an infinite sequence — every odd number, every power of two — is expressible at all, because take and take_while decide when to stop. What stays the same is the algorithm underneath: std::ranges::sort is introsort, std::ranges::find is a linear scan, and every complexity in lesson 2's table holds. Ranges change how a call is written, not what it costs.

Pitfalls

MistakeWhat happens
Iterating a const filter_viewCompile error — begin() is non-const because it caches
std::vector<int>(r.begin(), r.end()) on a filtered pipelineCompile error — iterator and sentinel differ; use ranges::copy or views::common
Ending a pipeline in std::ranges::to<std::vector>()C++23; does not compile on this runtime
A view of a local that goes out of scopeDangling — an lvalue source must outlive the view
Walking an expensive transform twiceThe function runs twice per element
Mutating the source after a filter's first passStale cached begin()
Passing a std::list to std::ranges::sortConstraint failure: not random_access_iterator

Key takeaways

  • std::ranges:: algorithms take the container, check it with concepts, and accept a projection: sort(v, {}, &T::member).
  • Views are lazy, non-owning recipes composed with |; filter, transform, take, drop, reverse and iota cover most pipelines.
  • Nothing runs until the view is walked, so take on an infinite iota terminates.
  • Materialise with std::ranges::copy(view, std::back_inserter(out)) — ranges::to is C++23 and unavailable here.
  • filter caches and is not const-iterable; transform recomputes on every read; a view must not outlive its source.
  • Ranges change the spelling, not the cost: the same algorithms and complexities sit underneath.

Common questions

What is a projection in std::ranges algorithms?

A projection is a callable applied to each element before the comparator or predicate sees it, passed as the last argument. std::ranges::sort(people, {}, &Person::age) sorts by age with the default std::ranges::less, and std::ranges::find(people, name, &Person::name) searches one member. A pointer-to-member is the commonest projection.

How do you convert a view to a vector in C++20?

Copy it out: std::ranges::copy(view, std::back_inserter(out));. The iterator-pair constructor often fails because filter and take produce an end sentinel of a different type from the iterator; std::views::common bridges that. std::ranges::to<std::vector>() is the neater answer, but it is C++23.

Why are C++20 views lazy?

A view is a recipe, not a result: filter runs its predicate and transform its function only when an element is requested by a loop or an algorithm. That is why std::views::iota(1) | std::views::take(5) terminates although iota(1) is infinite — take stops asking after five elements. It also means a transform view recomputes on every read.

Why can't I iterate a const filter_view?

std::views::filter caches the position of the first matching element the first time begin() is called, so its begin() is not const, and a const filter view does not compile in a range-for. Build the view and walk it without const, and do not modify the source between walks, or the cached position goes stale.

Can a view outlive the container it refers to?

No. A view built from an lvalue container holds a reference to it, so returning v | std::views::filter(pred) from the function that owns v leaves a dangling view. A pipeline started from an rvalue container moves it into an owning_view and is safe; otherwise return a std::vector.

Mark as readSign in to keep your place.

C++20 Ranges and Views Explained: Pipelines and Projections

C++20 range algorithms take the container and a projection; views build lazy pipelines with the pipe operator. How to materialise a view and what dangles.

  • Course: C++ study plan
  • Module: Algorithms and lambdas
  • Kind: Lesson
  • Reading time: 15 min
  • Runtime: Clang 18 · C++20

What are ranges and views in C++20?

C++20 ranges, in <ranges>, are a new layer over the STL. The constrained algorithms in std::ranges take a whole container, check it with concepts and accept a projection, as in std::ranges::sort(people, {}, &Person::age). Views in std::views, such as filter, transform and take, are lazy, non-owning adaptors composed with |: nothing is computed until the pipeline is iterated.

Lesson

Every algorithm so far took two iterators, and every pipeline needed an intermediate vector per step. C++20's <ranges> fixes both. The constrained algorithms in std::ranges:: take the container itself and a projection that says which part of each element to look at; the views in std::views:: are lazy, non-owning adaptors that compose with | into a pipeline that computes nothing until it is walked. This lesson settles what each brings, how a pipeline is materialised into a vector on a C++20 runtime, and the handful of rules — caching, const, re-evaluation, lifetime — that keep views honest.

Constrained algorithms and projections

#include <algorithm>
#include <ranges>

std::vector<int> v{5, 3, 8, 1};
std::ranges::sort(v);                                              // whole container, no begin/end
auto it = std::ranges::find(v, 8);                                 // an iterator, or v.end()
auto n  = std::ranges::count_if(v, [](int x) { return x > 2; });

std::ranges::sort(v) is std::sort(v.begin(), v.end()) with the iterators taken for you and, crucially, with concepts (Module 12, lesson 5) on the parameters: passing a std::list fails with a constraint error that names random_access_iterator, not with forty lines of template noise. The iterator-pair forms still exist, and the end may be a sentinel of a different type from the iterator. Return values changed in a few places — std::ranges::minmax(v) returns a struct with .min and .max values, std::ranges::minmax_element the same with iterators, and std::ranges::sort returns the end of the range.

The extra parameter is the projection: a callable applied to each element before the comparator or predicate sees it.

struct Person { std::string name; int age; };
std::vector<Person> people = readPeople();
std::ranges::sort(people, {}, &Person::age);                                     // by age, default less
std::ranges::sort(people, std::ranges::greater{}, &Person::age);                 // oldest first
auto adults = std::ranges::count_if(people, [](int a) { return a >= 18; }, &Person::age);
auto ann    = std::ranges::find(people, std::string{"ann"}, &Person::name);
std::ranges::sort(people, {}, [](const Person& p) { return std::tie(p.age, p.name); });   // age, then name

{} is the default comparator std::ranges::less; a pointer-to-member is the commonest projection, and a lambda returning a std::tie is how to sort by a key with a tie-break. The comparator sees ints, the predicate sees ints, find compares a std::string — each part of the call does one job, where the classic lambda comparator did three.

Views

std::vector<int> v{1, 2, 3, 4, 5, 6, 7, 8};
auto evens    = v | std::views::filter([](int x) { return x % 2 == 0; });      // 2 4 6 8
auto squares  = evens | std::views::transform([](int x) { return x * x; });     // 4 16 36 64
auto firstTwo = squares | std::views::take(2);                                  // 4 16
for (int x : firstTwo) std::cout << x << ' ';

A view is a lightweight object that refers to a range and presents it differently. Nothing is computed when the view is created: filter runs its predicate and transform its function only when an element is asked for, by the for loop or an algorithm — laziness. A view is non-owning (it holds the vector by reference, so the vector must outlive it), cheap to copy, and composed with |: the pipeline v | filter | transform | take reads left to right, with no intermediate containers, and stops after two elements because take never asks for a third.

AdaptorYields
views::filter(pred)the elements for which pred is true
views::transform(f)f(element) for each element
views::take(n) / views::drop(n)the first n / everything after the first n
views::take_while(pred) / views::drop_while(pred)a prefix / the rest, decided by pred
views::reversethe range backwards (bidirectional or better)
views::iota(a, b) / views::iota(a)the integers in [a, b) / from a without end
views::keys / views::valuesthe .first / .second of each pair — a map's keys
for (int i : std::views::iota(1, 6)) std::cout << i;                             // 12345
auto odds = std::views::iota(1) | std::views::filter([](int x) { return x % 2; })
                                | std::views::take(3);                            // 1 3 5, from an infinite range
for (const auto& k : ages | std::views::keys) std::cout << k << ' ';              // a map's keys, in order
for (int x : v | std::views::reverse | std::views::drop(1)) std::cout << x;       // 7654321

views::iota(1) with no upper bound is infinite, and the pipeline still terminates because take(3) stops pulling — the clearest demonstration that a view is a recipe, not a result. std::ranges::for_each, count_if, find, max_element and the rest accept a view wherever they accept a container.

Materialising a view

A view is not a vector: it has no size() unless every adaptor preserves one (filter does not), cannot be indexed unless random access survives, and cannot be returned from a function that lets the source die. To keep the results, copy them out:

#include <iterator>

std::vector<long long> out;
std::ranges::copy(v | std::views::filter(positive) | std::views::transform(square) | std::views::take(k),
                  std::back_inserter(out));

std::ranges::copy into a std::back_inserter is the C++20 answer. The iterator-pair constructor std::vector<int>(r.begin(), r.end()) does not compile for a pipeline like this: filter and take produce an end() that is a sentinel of a different type from begin(), and the constructor wants two of the same. r | std::views::common bridges the two when an old interface insists on an iterator pair. C++23 adds std::ranges::to<std::vector>() at the end of a pipeline, plus std::views::enumerate, zip, chunk and adjacent — worth recognising in modern code and in interviews, but this track's runtime is C++20 and none of them compile here: ranges::copy and back_inserter are the tools.

What to keep in mind

filter caches begin(). Finding the first passing element costs a scan, so filter_view remembers where it is. Two consequences: the view's begin() is not const, so a const auto evens = v | std::views::filter(…) cannot be iterated (compile error), and changing the underlying vector after the first pass leaves the cache stale. Build the view, walk it, and do not mutate the source in between.

transform recomputes. The function runs every time an element is read — walk a transform view twice and pay twice; index it in a loop and pay per access. Materialise when the function is expensive or the result is read more than once.

Views dangle like references. A view built from an lvalue holds a reference to it: return v | std::views::filter(…) from the function that owns v, or keep the view in a member after the vector is gone, and the view refers to a dead object — a bug of exactly Module 6's kind. (A pipeline started from an rvalue container, getVector() | std::views::filter(…), moves the container into an owning_view and is safe.) When you must return the data, return a vector.

Adaptors lose capabilities. filter drops random access and size(); reverse needs a bidirectional source; transform keeps the category but, when its function returns by value, yields temporaries you cannot assign through. When an algorithm refuses a view, the concept named in the error is the missing capability.

What ranges fix

The classic std::sort(v.begin(), v.end(), [](const Person& a, const Person& b) { return a.age < b.age; }) becomes std::ranges::sort(people, {}, &Person::age); the filter–map–reduce pipeline of lesson 3, with its two intermediate vectors, becomes one expression with none; "the first k elements that match" stops being a loop with a counter; and an infinite sequence — every odd number, every power of two — is expressible at all, because take and take_while decide when to stop. What stays the same is the algorithm underneath: std::ranges::sort is introsort, std::ranges::find is a linear scan, and every complexity in lesson 2's table holds. Ranges change how a call is written, not what it costs.

Pitfalls

MistakeWhat happens
Iterating a const filter_viewCompile error — begin() is non-const because it caches
std::vector<int>(r.begin(), r.end()) on a filtered pipelineCompile error — iterator and sentinel differ; use ranges::copy or views::common
Ending a pipeline in std::ranges::to<std::vector>()C++23; does not compile on this runtime
A view of a local that goes out of scopeDangling — an lvalue source must outlive the view
Walking an expensive transform twiceThe function runs twice per element
Mutating the source after a filter's first passStale cached begin()
Passing a std::list to std::ranges::sortConstraint failure: not random_access_iterator

Key takeaways

  • std::ranges:: algorithms take the container, check it with concepts, and accept a projection: sort(v, {}, &T::member).
  • Views are lazy, non-owning recipes composed with |; filter, transform, take, drop, reverse and iota cover most pipelines.
  • Nothing runs until the view is walked, so take on an infinite iota terminates.
  • Materialise with std::ranges::copy(view, std::back_inserter(out)) — ranges::to is C++23 and unavailable here.
  • filter caches and is not const-iterable; transform recomputes on every read; a view must not outlive its source.
  • Ranges change the spelling, not the cost: the same algorithms and complexities sit underneath.

Common questions

What is a projection in std::ranges algorithms?

A projection is a callable applied to each element before the comparator or predicate sees it, passed as the last argument. std::ranges::sort(people, {}, &Person::age) sorts by age with the default std::ranges::less, and std::ranges::find(people, name, &Person::name) searches one member. A pointer-to-member is the commonest projection.

How do you convert a view to a vector in C++20?

Copy it out: std::ranges::copy(view, std::back_inserter(out));. The iterator-pair constructor often fails because filter and take produce an end sentinel of a different type from the iterator; std::views::common bridges that. std::ranges::to<std::vector>() is the neater answer, but it is C++23.

Why are C++20 views lazy?

A view is a recipe, not a result: filter runs its predicate and transform its function only when an element is requested by a loop or an algorithm. That is why std::views::iota(1) | std::views::take(5) terminates although iota(1) is infinite — take stops asking after five elements. It also means a transform view recomputes on every read.

Why can't I iterate a const filter_view?

std::views::filter caches the position of the first matching element the first time begin() is called, so its begin() is not const, and a const filter view does not compile in a range-for. Build the view and walk it without const, and do not modify the source between walks, or the cached position goes stale.

Can a view outlive the container it refers to?

No. A view built from an lvalue container holds a reference to it, so returning v | std::views::filter(pred) from the function that owns v leaves a dangling view. A pipeline started from an rvalue container moves it into an owning_view and is safe; otherwise return a std::vector.

Exercises

First k squares of the positives

Read an integer n, then n integers, then an integer k. Build one pipeline over the vector: std::views::filter keeps the strictly positive values, std::views::transform squares each as a long long, and std::views::take keeps the first k. Materialise it with std::ranges::copy into a std::vector<long long> through std::back_inserter (std::ranges::to is C++23 and not available here).

Print the vector's values on one line separated by single spaces, or none when it is empty; then sum=<total> from std::accumulate with 0LL.

Input: n, the values, k. Output: two lines.

Example: 7 then 3 -1 4 0 5 -9 2 then 3 →

9 16 25
sum=50

Youngest and oldest by projection

Read an integer n, then n lines of name age (distinct single-word names, integer ages), then an integer k. Sort the people with std::ranges::sort and a projection — a lambda returning std::tie(p.age, p.name) so that equal ages are ordered by name — with the default comparator ({}), not a comparator lambda. Then print youngest followed by the first k people through people | std::views::take(k), and oldest followed by the first k of people | std::views::reverse. When k exceeds n, each list is everyone.

Input: n, then n lines, then k. Output: youngest, up to k lines of name age, oldest, up to k lines.

5
ann 19
zed 71
bob 23
yan 65
max 40
2

prints

youngest
ann 19
bob 23
oldest
zed 71
yan 65

In this module: Algorithms and lambdas

← Lambdas in depth — captures, closures, std::function and recursion · Checkpoint — Algorithms and lambdas →