Ranges

C++20's <ranges> library lets you pass a container directly to an algorithm instead of a pair of iterators, and adds composable, lazy views like filter and transform that build entire processing pipelines without allocating a single intermediate container.

Every classic STL algorithm takes a pair of iterators: std::sort(v.begin(), v.end()) works because begin() and end() describe where a range of elements starts and stops. That design is powerful but verbose, and it has a sharp edge — nothing stops you from accidentally mixing iterators from two different containers, since the compiler only sees two independent iterator arguments with no relationship enforced between them. C++20's <ranges> library fixes both problems at once: a range is anything with a begin and an end, algorithms in std::ranges take a single range argument instead of an iterator pair, and a new family of lazy, non-owning wrappers called views lets you express transformations like filtering and mapping as a pipeline that computes nothing until you actually iterate over it. This page covers what a range is formally, how views differ from eager algorithms, the core filter/transform/take/drop views, how pipelines compose with the | operator, and where iterators and sentinels still show up underneath it all.

The Iterator-Pair Problem

std::sort(v.begin(), v.end()) requires the caller to supply two iterators that are actually consistent with each other — both from the same container, in the right order. Nothing in the type system enforces that relationship, so std::sort(a.begin(), b.end()) compiles perfectly fine and produces undefined behavior if a and b are different containers.

std::ranges::sort(values) takes the container itself, so there is no way to accidentally pass mismatched begin/end iterators from two different containers — the single argument is self-consistent by construction. std::ranges algorithms are also constrained with C++20 concepts, so passing something that is not actually a valid range produces a clearer compiler error pointing at the constraint that failed, rather than a wall of template-instantiation errors.

#include <algorithm>
#include <ranges>
#include <vector>

std::vector<int> values = {5, 3, 8, 1, 9};

// Classic form: two iterators, no enforced relationship between them.
std::sort(values.begin(), values.end());

// Ranges form: one range argument, the same operation.
std::ranges::sort(values);

What Is a Range?

Formally, a range is anything that has a begin() returning an iterator and an end() returning a sentinel that can be compared against that iterator for equality — the std::ranges::range concept checks exactly that. Containers like std::vector and std::map model it automatically, as do plain C arrays, and so do the view types this page builds up to. A hierarchy of more specific concepts mirrors the classic iterator category hierarchy — std::ranges::input_range, std::ranges::forward_range, std::ranges::random_access_range, and so on — describing what kind of traversal a given range supports.

The template parameter constraint std::ranges::range R restricts first_element to types that actually satisfy the range concept. Passing something that isn't a range — an int, for instance — fails to compile with a constraint-violation error naming exactly which requirement wasn't met, rather than a cryptic error buried inside the function body.

#include <ranges>
#include <vector>

template <std::ranges::range R>
auto first_element(const R& r)
{
    return *r.begin();
}

std::vector<int> values = {10, 20, 30};
int x = first_element(values); // OK: std::vector<int> models range

Views: Lazy, Non-Owning Ranges

A view is a special kind of range: cheap to copy or move (typically just a few pointers or iterators), and it does not own the elements it presents — it wraps another range and describes a transformation of it that is computed lazily, element by element, as you iterate, rather than all at once up front. This is the sharpest difference from an algorithm like std::transform, which eagerly writes a fully computed output range the moment it is called.

After the transform call, doubled is a fully materialized std::vector<int> containing {2, 4, 6, 8, 10}. After the views::transform expression, doubled_view holds no computed values at all yet — it is a lightweight object that remembers values and the doubling function, and it only actually multiplies an element by two the moment something iterates over doubled_view and asks for that element.

#include <algorithm>
#include <ranges>
#include <vector>

std::vector<int> values = {1, 2, 3, 4, 5};
std::vector<int> doubled;

// Eager: std::transform computes every value immediately and writes it out.
std::ranges::transform(values, std::back_inserter(doubled), [](int x) { return x * 2; });

// Lazy: views::transform describes the operation but computes nothing yet.
auto doubled_view = values | std::views::transform([](int x) { return x * 2; });

filter, transform, take, drop — Core Views

std::views::filter(pred) yields only the elements satisfying pred. std::views::transform(fn) yields fn applied to each element. std::views::take(n) yields at most the first n elements. std::views::drop(n) skips the first n elements and yields the rest. All four can be written either as function calls, filter(v, pred), or piped with the | operator, v | filter(pred), which reads left to right as a pipeline of stages.

The pipeline keeps even numbers, squares them, and takes the first three results. Nothing is computed when pipeline is constructed — only when the range-for loop starts pulling elements does each stage actually run, one element at a time, for exactly as many elements as take(3) needs.

The for loop asks pipeline for its first element.

That pulls from filter, which pulls from values starting at 1, skipping odd numbers until it finds 2.

transform squares 2, producing 4, which take(3) allows through as its first element.

The loop prints 4, then repeats: filter finds 4 next, transform squares it to 16, take allows it as the second element.

filter finds 6 next, transform squares it to 36, take allows it as the third and final element, and the pipeline stops producing further values.

Output: 4 16 36

#include <iostream>
#include <ranges>
#include <vector>

int main()
{
    std::vector<int> values = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

    auto pipeline = values
        | std::views::filter([](int x) { return x % 2 == 0; })
        | std::views::transform([](int x) { return x * x; })
        | std::views::take(3);

    for (int x : pipeline)
        std::cout << x << " ";
    std::cout << "\n";

    return 0;
}

Range Pipelines and Why Laziness Matters

Because each stage of a pipeline pulls one element through at a time rather than processing the whole range at each stage before moving to the next, a pipeline never allocates an intermediate container between filter and transform and take the way writing three separate loops into three separate vectors would. That matters even more once a range can be infinite: std::views::iota(1) produces an unbounded, ever-increasing sequence of integers with no natural end, and combining it with take(5) works perfectly fine precisely because nothing downstream ever asks the pipeline to finish before yielding results.

std::views::iota(1) has no end at all, yet this compiles and runs fine, terminating after exactly five values because take(5) only ever pulls five elements through the pipeline. An eager approach — materializing iota's output into a container first — would never finish, since there is no way to eagerly compute an infinite range.

#include <iostream>
#include <ranges>

int main()
{
    auto first_five_squares = std::views::iota(1)
        | std::views::transform([](int x) { return x * x; })
        | std::views::take(5);

    for (int x : first_five_squares)
        std::cout << x << " ";
    std::cout << "\n"; // 1 4 9 16 25

    return 0;
}

Iterators vs Ranges — Sentinels

Classic STL iterators require begin() and end() to return the same type, so "reaching the end" always means an equality comparison between two objects of identical type. Ranges relax that: end() can return a different type, called a sentinel, as long as it supports == against the iterator type. That is exactly what makes something like std::views::iota(1) (an unbounded range with no meaningful end value at all) or a range terminated by a predicate rather than a fixed position expressible within the same range abstraction used everywhere else.

In practice this is mostly invisible when using views day to day — you write range-for loops and pipe expressions, and the sentinel machinery underneath just works. It matters most when writing generic code that manipulates iterators and sentinels directly, where std::ranges::iterator_t<R> and std::ranges::sentinel_t<R> may legitimately be two different types for the same range R.

Materializing a View Into a Container

A view computes nothing until consumed, and it does not own its elements — sooner or later, most pipelines need their results copied into an actual owning container, either to store them past the lifetime of the underlying range or to pass them to code expecting a concrete container type. Since a view models the range concept (it has begin() and end()), constructing a container directly from those iterators works in C++20; C++23 added std::ranges::to<Container>() as a cleaner, dedicated syntax for the same operation.

even_view itself never stores {2, 4, 6} anywhere — it recomputes the filter on demand every time it is iterated. Constructing evens from even_view.begin() and even_view.end() is what finally walks the view once and copies its results into a real, owning std::vector<int>.

What problem with the classic iterator-pair calling convention (std::sort(v.begin(), v.end())) does passing a single range to std::ranges::sort avoid?

With two independent iterator arguments, nothing enforces that they come from the same container or are in the right order — std::sort(a.begin(), b.end()) compiles and produces undefined behavior. std::ranges::sort takes one range argument, so the two "ends" of the range are always self-consistent by construction, and the argument is also constrained by a concept, giving clearer errors for genuinely invalid inputs.

What formally makes something a range, according to the std::ranges::range concept?

It has a begin() that returns an iterator and an end() that returns a sentinel that can be compared for equality against that iterator. Containers, plain arrays, and views all satisfy this; more specific concepts like input_range and random_access_range further constrain what kind of traversal is supported.

How does std::views::transform differ from std::ranges::transform in terms of when the transformation is actually computed?

std::ranges::transform is eager: it runs the function on every element immediately and writes a fully computed output range right away. std::views::transform is lazy: it returns a view that remembers the source range and the function but computes nothing until something iterates over the view, applying the function one element at a time as each element is actually requested.

Why does combining std::views::iota(1) with std::views::take(5) work perfectly fine even though iota(1) produces an unbounded sequence?

Because views pull elements through a pipeline lazily, one at a time, on demand. take(5) only ever asks the upstream iota view for five elements total, so the fact that iota could keep producing values forever never matters — nothing downstream ever tries to exhaust it or waits for it to finish.

Why is auto v = std::vector<int>{1,2,3} | std::views::filter(pred); dangerous, and how should it be fixed?

A view is non-owning and only wraps whatever range it was built from; it does not keep that range alive. Here the vector is a temporary that is destroyed at the end of the full expression, so v ends up viewing already-destroyed memory. The fix is to store the vector in a named variable that outlives the view, and build the view from that named variable instead of from a temporary.

What does this program print? Trace the pipeline stage by stage.

It prints "4 6". drop(2) skips the first two elements (1 and 2), leaving 3, 4, 5, 6, 7, 8 available to the next stage. filter keeps only even values from that remaining sequence: 4, 6, 8. take(2) then allows only the first two values that make it through filter — 4 and 6 — and stops the pipeline before 8 is ever pulled through, since take(2) has already been satisfied.

Write a C++20 program that uses a range pipeline (views::filter and views::transform, composed with the | operator) to take a vector of words, keep only words longer than three characters, uppercase-transform their first letter, and print the results, without writing the intermediate results into any named container.

filter keeps only words with more than three characters — "elephant" and "giraffe" survive, while "cat", "dog", and "ox" are dropped. transform then capitalizes each surviving word's first letter, lazily, as the range-for loop pulls each element through. The loop prints "Elephant Giraffe", and at no point does the program allocate a separate vector to hold the filtered or transformed intermediate results — the pipeline computes each output word on demand.

Write a C++20 program that materializes a filtered view of even numbers from a vector into a new std::vector<int> using the view's begin() and end() iterators, and prints both the original vector and the materialized result.

even_view never stores any elements itself — it recomputes the filter every time it is iterated. Constructing evens directly from even_view.begin() and even_view.end() walks the view exactly once and copies every element that passes the filter into a real, owning std::vector<int>. The original vector is printed unchanged (1 through 10), and evens prints the materialized result 2 4 6 8 10, now an independent container that no longer depends on values or the view at all.

#include <ranges>
#include <vector>

std::vector<int> values = {1, 2, 3, 4, 5, 6};

auto even_view = values | std::views::filter([](int x) { return x % 2 == 0; });

// C++20: construct directly from the view's begin/end.
std::vector<int> evens(even_view.begin(), even_view.end());
#include <iostream>
#include <ranges>
#include <vector>

int main()
{
    std::vector<int> values = {1, 2, 3, 4, 5, 6, 7, 8};

    auto pipeline = values
        | std::views::drop(2)
        | std::views::filter([](int x) { return x % 2 == 0; })
        | std::views::take(2);

    for (int x : pipeline)
        std::cout << x << " ";
    std::cout << "\n";
}