std::views — Lazy, Composable Pipelines
C++20's ranges library introduces views: lightweight, non-owning range adaptors that transform sequences lazily and compose with a pipe operator. Instead of allocating temporaries and spelling out begin()/ end() pairs repeatedly, you describe a data pipeline — and the compiler generates only the work that producing each element requires.
The problem views solve
Classic STL algorithms are powerful but verbose. Composing several of them on a container requires multiple temporary containers, repeated begin()/end() calls, and an accumulation of boilerplate that obscures the actual intent. Consider the task: given a vector of integers, print the squares of all even numbers, except the first two, in descending order.
Pre-C++20 — iterator-based
std::vector<int> v{ 1, 5, 3, 2, 8, 7, 6, 4 };
// copy even elements into a temp
std::vector<int> temp;
std::copy_if(v.begin(), v.end(),
std::back_inserter(temp),
[](int n){ return n % 2 == 0; });
// sort descending
std::sort(temp.begin(), temp.end(),
[](int a, int b){ return a > b; });
// remove the first two (smallest after sort)
temp.erase(temp.begin(),
temp.begin() + 2);
// square each remaining element
std::transform(temp.begin(), temp.end(),
temp.begin(),
[](int n){ return n * n; });
// print
std::for_each(temp.begin(), temp.end(),
[](int n){ std::cout << n << '\n'; });C++20 — views pipeline
#include <ranges>
std::vector<int> v{ 1, 5, 3, 2, 8, 7, 6, 4 };
std::ranges::sort(v);
namespace rv = std::ranges::views;
auto r = v
| rv::filter([](int n){ return n % 2 == 0; })
| rv::drop(2)
| rv::reverse
| rv::transform([](int n){ return n * n; });
for (int i : r)
std::cout << i << '\n';
// No temporaries. No begin()/end() pairs.
// Each element is only processed when
// the for-loop body requests it.The pipeline version reads like a description of the intent. More importantly, it allocates nothing — the views form a chain of lightweight wrappers that produce each element on demand as the range-for loop advances its iterator.
What a view is
A view is a range with additional constraints: it must have constant-time copy, move, and assignment operations. That restriction enforces the key invariant — a view can never own a large collection, because copying it must be cheap regardless of how many elements the underlying range contains. In practice this means views hold only a reference (or a small iterator pair) to an underlying sequence, not the elements themselves.
The other defining property of views is lazy evaluation. When you write v | rv::filter(pred) | rv::transform(fn), nothing is computed immediately. The view chain is an object describing the computation. Each element is only produced when an iterator is advanced — typically inside a range-for loop or an algorithm. This means a transform over a billion-element range is as cheap to construct as a transform over an empty one.
Views live in two namespaces. The type itself (e.g., std::ranges::filter_view) is in std::ranges. A variable template or range adaptor object of the same name (e.g., std::views::filter) lives in std::views (itself an alias for std::ranges::views). In practice you always use the shorthand std::views::filter or alias it to rv::filter.
// Two equivalent ways to create an iota view (generates 1,2,...,9)
for (auto i : std::ranges::iota_view{1, 10}) // using the type directly
std::cout << i << '\n';
for (auto i : std::views::iota(1, 10)) // using the adaptor object
std::cout << i << '\n';
// The adaptor object form is almost always preferred.
namespace rv = std::ranges::views; // common alias in examples belowThe range concept hierarchy
The C++20 <ranges> header defines a hierarchy of concepts in the std::ranges namespace that classify range types by capability. These concepts matter when writing constrained generic code and when understanding why some views cannot be piped together (a filter_view can only produce a forward range, for instance — not random access).
rangeBase concept. Provides begin() and an end sentinel. Iterator and sentinel may differ in type.
borrowed_rangeA range whose iterators remain valid after the range object is destroyed — safe to pass by value and get iterators from.
sized_rangeKnows its size in O(1) without traversal.
common_rangeIterator and sentinel have the same type — compatible with classic begin/end APIs.
viewA range with O(1) copy, move, and assignment. The formal concept that all view types satisfy.
viewable_rangeA range that can be safely converted to a view (i.e., can be passed to std::views::all).
input_range / output_rangeRequires the iterator to satisfy input_iterator or output_iterator respectively.
forward_rangeMulti-pass: you can iterate the range more than once.
bidirectional_rangeCan iterate forwards and backwards.
random_access_rangeO(1) indexed access (operator[]).
contiguous_rangeElements occupy contiguous memory — pointer arithmetic is valid.
View factories: generating ranges from scratch
Factories are views that produce a new range rather than adapting an existing one. They are the starting points of a pipeline when you don't have an existing container as input, or when you need a range that doesn't correspond to any particular container.
std::views::iota — arithmetic sequence
Generates consecutive values starting from a given value. Bounded form takes a start and past-the-end value; unbounded form takes only a start and continues indefinitely — you must limit it with take or a similar adaptor.
// Bounded: produces 1 2 3 4 5 6 7 8 9
for (auto i : std::views::iota(1, 10))
std::cout << i << ' ';
// Unbounded: produces the first 9 values starting at 1
auto first9 = std::views::iota(1)
| std::views::take(9);
for (auto i : first9)
std::cout << i << ' '; // same outputstd::views::istream — reading from a stream
Applies operator>> repeatedly on a stream, producing a range of parsed values. Useful for treating a stream as a lazy sequence of tokens without a manual read loop.
auto text = std::istringstream{"19.99 7.50 49.19 20 12.34"};
// Instead of a while(stream >> price) loop:
for (double price : std::ranges::istream_view<double>(text))
std::cout << price << '\n';std::views::empty and std::views::single
Produce a zero-element or one-element view. Most useful in generic code that must accept a range argument — instead of adding a separate overload for the empty case, callers pass an empty_view.
constexpr auto ev = std::views::empty<int>;
static_assert(std::ranges::empty(ev));
constexpr auto sv = std::ranges::single_view<int>{42};
static_assert(std::ranges::size(sv) == 1);
static_assert(*std::ranges::data(sv) == 42);Core C++20 range adaptors
Range adaptors transform an existing range into a new view. They are the building blocks of pipelines. Every adaptor wraps a source range and intercepts the iterator operations — operator++ on the view's iterator may advance the source iterator multiple times (for filter), or apply a function to the element before returning it (for transform). None of this work happens until you actually iterate.
rv::filter(pred)Keeps only elements that satisfy the predicate. The resulting view is at most a bidirectional range even if the source is random-access, because skipping elements during backward iteration requires scanning.
std::vector<int> v{1, 2, 3, 4, 5, 6};
for (int i : v | rv::filter([](int n){ return n % 2 == 0; }))
std::cout << i << ' '; // 2 4 6rv::transform(fn)Applies a function to every element and returns the result. The view preserves the iterator category of the source (random-access stays random-access). The function is called each time the element is dereferenced.
std::vector<int> v{1, 2, 3, 4};
for (int i : v | rv::transform([](int n){ return n * n; }))
std::cout << i << ' '; // 1 4 9 16rv::take(n)Produces a view of the first n elements. Stops iteration after n elements have been produced, even if the source is unbounded. Pair this with iota to create finite sequences from infinite ranges.
// First 5 natural numbers
for (int i : std::views::iota(1) | rv::take(5))
std::cout << i << ' '; // 1 2 3 4 5rv::take_while(pred)Yields elements from the beginning until the predicate returns false for the first time. That element and everything after it is excluded, even if later elements would satisfy the predicate.
std::vector<int> v{1, 5, 3, 2, 4, 7, 16, 8};
for (int i : v | rv::take_while([](int n){ return n < 10; }))
std::cout << i << ' '; // 1 5 3 2 4 7rv::drop(n)Skips the first n elements and produces the rest. Complementary to take: together they can slice any range into a window.
std::vector<int> v{1, 2, 3, 4, 5};
for (int i : v | rv::drop(2))
std::cout << i << ' '; // 3 4 5rv::drop_while(pred)Skips elements from the beginning while the predicate is true, then yields everything from the first non-matching element onwards.
std::vector<int> v{1, 5, 3, 2, 4, 7};
// drop initial odd numbers
for (int i : v | rv::drop_while([](int n){ return n % 2 == 1; }))
std::cout << i << ' '; // 2 4 7rv::reverseIterates the underlying range in reverse. Requires the source to be a bidirectional range. Note: reverse has no arguments, so in pipe syntax it appears without parentheses.
std::vector<int> v{1, 2, 3, 4, 5};
for (int i : v | rv::reverse)
std::cout << i << ' '; // 5 4 3 2 1rv::joinFlattens a range-of-ranges into a single range. Each inner range is traversed in order. Useful for flattening nested containers without allocating a new container.
std::vector<std::vector<int>> v{{1,2,3}, {4}, {5,6}};
for (int i : v | rv::join)
std::cout << i << ' '; // 1 2 3 4 5 6rv::split(delim)Splits a range on a delimiter (or delimiter range), producing a range of subranges. Each subrange is a view into the original — no allocations. Often used to split strings without copying.
std::string text{"this is a demo"};
constexpr std::string_view delim{" "};
for (auto word : text | rv::split(delim)) {
std::cout << std::string_view{word.begin(), word.end()} << '\n';
}rv::keys rv::values rv::elements<N>Project a range of pair-like or tuple-like elements onto their Nth element. keys extracts element 0 (equivalent to elements<0>), values extracts element 1, and elements<N> extracts any element by index.
std::vector<std::tuple<int,double,std::string>> v{
{1, 1.1, "one"}, {2, 2.2, "two"}, {3, 3.3, "three"}
};
for (int k : v | rv::keys) std::cout << k; // 1 2 3
for (auto d : v | rv::values) std::cout << d; // 1.1 2.2 3.3
for (auto s : v | rv::elements<2>) std::cout << s; // one two threeHow the pipe operator works
The | operator is overloaded by every range adaptor to enable left-to-right composition. The rules depend on whether the adaptor takes arguments beyond the range itself:
Adaptor takes only the range (no extra arguments)
A(V) ≡ V | Arv::reverse takes only the source range → v | rv::reverse is equivalent to rv::reverse(v)
Adaptor takes the range plus extra arguments
A(V, args…) ≡ A(args…)(V) ≡ V | A(args…)rv::take(v, 2) ≡ rv::take(2)(v) ≡ v | rv::take(2)
Piping produces a new view object of a type that combines both adaptors. The underlying type of a long chain becomes a nested template — but you almost always hold it with auto, so the type stays hidden.
namespace rv = std::ranges::views;
std::vector<int> v{ 1, 5, 3, 2, 8, 7, 6, 4 };
// These are all equivalent:
auto r1 = rv::reverse(rv::drop(rv::filter(v, [](int n){ return n % 2 == 0; }), 2));
auto r2 = v | rv::filter([](int n){ return n % 2 == 0; }) | rv::drop(2) | rv::reverse;
// Chain views across lines for readability:
auto r3 = v
| rv::filter([](int n){ return n % 2 == 0; })
| rv::drop(2)
| rv::reverse;
// All three hold the same lazy computation — no elements are produced yet.Practical pipeline patterns
The real value of views emerges when several adaptors are chained to express a non-trivial query without intermediate allocations. Each of the following examples reads the pipeline left to right and requires no temporary containers.
Last two odd numbers in a sequence, printed in reverse order
std::vector<int> v{1, 5, 3, 2, 4, 7, 6, 8};
namespace rv = std::ranges::views;
for (auto i : v
| rv::reverse
| rv::filter([](int n){ return n % 2 == 1; })
| rv::take(2))
std::cout << i << '\n'; // prints 7 then 3Subsequence of numbers below 10, skipping leading odds
std::vector<int> v{1, 5, 3, 2, 4, 7, 16, 8};
namespace rv = std::ranges::views;
for (auto i : v
| rv::take_while([](int n){ return n < 10; })
| rv::drop_while([](int n){ return n % 2 == 1; }))
std::cout << i << '\n'; // prints 2 4 7Map keys sorted and uppercased (std::map iteration)
std::map<std::string, int> scores{
{"alice", 92}, {"bob", 87}, {"carol", 95}
};
namespace rv = std::ranges::views;
// std::map iterates in sorted key order already;
// keys view extracts just the string keys.
for (auto const& name : scores | rv::keys)
std::cout << name << '\n'; // alice bob carolGenerate a multiplication table row with iota + transform
namespace rv = std::ranges::views;
int row = 7;
for (int product : rv::iota(1, 11)
| rv::transform([row](int n){ return row * n; }))
std::cout << product << ' '; // 7 14 21 28 35 42 49 56 63 70C++23 additions (preview)
C++23 extends the views catalog with several adaptors that were too useful to wait for a later standard. The most commonly needed are listed here; the dedicated lesson covers all of them with full examples.
zip(r1, r2, …)Combines multiple ranges element-by-element into tuples. Stops at the shortest range. Replaces the common pattern of using a loop index to correlate parallel arrays.
zip_transform(fn, r1, r2, …)Like zip, but passes the N-tuple of elements directly to an invocable and yields the result.
adjacent<N>Produces a view of N-tuples of consecutive elements from a single range — e.g., adjacent<2> yields (v[0],v[1]), (v[1],v[2]), …
adjacent_transform<N>(fn)Like adjacent<N> but applies an invocable to each N-tuple and yields the result.
join_with(delim)Like join, but inserts a delimiter element (or delimiter range) between each inner range when flattening.
chunk(n)Splits the range into consecutive non-overlapping chunks of size n.
slide(n)Produces overlapping windows of size n — like a sliding N-gram over the range.
Quick reference: all C++20 range adaptors
| Adaptor (std::views::) | Type (std::ranges::) | What it does |
|---|---|---|
| filter(pred) | filter_view | Keep elements satisfying pred |
| transform(fn) | transform_view | Apply fn to each element |
| take(n) | take_view | First n elements |
| take_while(pred) | take_while_view | Elements until pred is false |
| drop(n) | drop_view | Skip first n elements |
| drop_while(pred) | drop_while_view | Skip while pred is true |
| reverse | reverse_view | Iterate in reverse order |
| join | join_view | Flatten range-of-ranges |
| split(delim) | split_view | Split on delimiter, yield subranges |
| lazy_split(delim) | lazy_split_view | Like split but works with input ranges |
| keys | keys_view | First element of each pair/tuple |
| values | values_view | Second element of each pair/tuple |
| elements<N> | elements_view | Nth element of each tuple-like |
| all | — | Wrap any range into a view (ref_view or owning_view) |
| counted(it, n) | — | View of n elements starting at iterator it |
| common | common_view | Adapt iterator+sentinel to common iterator type |