Skip to content
C++

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 below

The 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).

range

Base concept. Provides begin() and an end sentinel. Iterator and sentinel may differ in type.

borrowed_range

A range whose iterators remain valid after the range object is destroyed — safe to pass by value and get iterators from.

sized_range

Knows its size in O(1) without traversal.

common_range

Iterator and sentinel have the same type — compatible with classic begin/end APIs.

view

A range with O(1) copy, move, and assignment. The formal concept that all view types satisfy.

viewable_range

A range that can be safely converted to a view (i.e., can be passed to std::views::all).

input_range / output_range

Requires the iterator to satisfy input_iterator or output_iterator respectively.

forward_range

Multi-pass: you can iterate the range more than once.

bidirectional_range

Can iterate forwards and backwards.

random_access_range

O(1) indexed access (operator[]).

contiguous_range

Elements 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 output

std::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 6
rv::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 16
rv::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 5
rv::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 7
rv::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 5
rv::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 7
rv::reverse

Iterates 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 1
rv::join

Flattens 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 6
rv::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 three

How 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 | A

rv::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 3

Subsequence 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 7

Map 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  carol

Generate 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 70

C++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_viewKeep elements satisfying pred
transform(fn)transform_viewApply fn to each element
take(n)take_viewFirst n elements
take_while(pred)take_while_viewElements until pred is false
drop(n)drop_viewSkip first n elements
drop_while(pred)drop_while_viewSkip while pred is true
reversereverse_viewIterate in reverse order
joinjoin_viewFlatten range-of-ranges
split(delim)split_viewSplit on delimiter, yield subranges
lazy_split(delim)lazy_split_viewLike split but works with input ranges
keyskeys_viewFirst element of each pair/tuple
valuesvalues_viewSecond element of each pair/tuple
elements<N>elements_viewNth element of each tuple-like
allWrap any range into a view (ref_view or owning_view)
counted(it, n)View of n elements starting at iterator it
commoncommon_viewAdapt iterator+sentinel to common iterator type
Sign in to track progress