Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Ranges and views

What a view is

A view is a lightweight, non-owning handle over a sequence. It borrows the elements and never allocates memory. The view does not manage the lifetime of its elements. The same principle underlies std::string_view and std::span that were introduced earlier. Both types expose a pointer and a length, and they refuse to copy the data. A range is any object that provides begin() and end() that return iterators. A view is a range that does not own its elements. In other words, every view is a range, but not every range is a view.

Because a view never allocates, construction is a constant‑time pointer‑plus‑size operation. The compiler can inline it and no heap traffic occurs. The view inherits the lifetime constraints of its underlying storage, so a dangling std::span results if the source is destroyed, which the compiler warns about. Declaring a parameter as std::span<const T> also signals that the function reads elements without taking ownership, mirroring std::string_view for read‑only text. This combination of zero‑allocation construction and intent‑driven design reduces accidental copies and clarifies ownership boundaries across API surfaces.

A view has a precise definition in the standard. A type is a view if it is a range, is cheap to copy or move, and does not own the elements it presents. std::ranges even provides std::ranges::owning_view to wrap an owning container into the view model when an API demands a view but you must keep ownership locally. The inverse, std::ranges::ref_view, wraps a reference to a range you already own. Knowing which wrapper applies prevents both dangling and accidental copies.

std::views adaptors

In the <ranges> header, a namespace std::views provides a family of adaptors. Each adaptor returns a new view that lazily transforms the underlying range. The most useful adaptors are listed below.

  • filter: keeps only elements that satisfy a predicate.
  • transform: applies a function to each element.
  • take: stops after a given number of elements.
  • drop: skips a given number of elements.
  • reverse: iterates the range in reverse order.
  • split: splits a range on a delimiter.
  • iota: generates an infinite arithmetic progression.

These adaptors compose by piping (|). The pipe operator forwards the left-hand side as the source range to the right-hand adaptor, which returns a new view. Because each adaptor is itself a view, the resulting expression is a chain of lightweight objects that never allocate until the final consumption point.

The following example builds a pipeline that generates the integers from 1 to 10, keeps the even numbers, squares them, and takes the first three results. It then prints the numbers.

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

int main() {
    // Generate numbers 1..10, keep evens, square them, take first three.
    auto rng = std::views::iota(1, 11)                 // 1..10 (exclusive upper bound)
               | std::views::filter([](int x){ return x % 2 == 0; })
               | std::views::transform([](int x){ return x * x; })
               | std::views::take(3);
    for (int v : rng) {
        std::cout << v << ' ';
    }
    std::cout << '\n';
}

It prints the three even squares 4 16 36. The EXPECT string in the build file verifies that these three numbers appear in the output.

Beyond the basic adaptors, the standard library provides combinators such as views::split for tokenising a string on a delimiter and views::reverse for reverse iteration without copying. The adaptor set also covers structure: views::chunk groups elements into fixed-size subranges, views::slide produces overlapping windows, and views::elements extracts the Nth member of each tuple‑like element (e.g., views::elements<0> extracts keys from a range of pairs). For associative containers, views::keys and views::values expose just the key or mapped type without copying. These utilities replace verbose loops and temporary containers with concise pipelines.

Laziness

Views are evaluated on demand. The pipeline does not create a temporary container after each adaptor. The take(3) adaptor stops the source after three elements have been produced. This property enables short-circuiting of expensive sources.

Consider a situation where the source is an unbounded range, such as std::views::iota(0). Without laziness, materialising the whole range requires infinite memory and never terminates. The lazy view evaluates each element only when the downstream consumer asks for it, and the take adaptor caps the evaluation.

The next example constructs an endless iota range, squares each value, and then takes only the first five results. Even though the source can produce an infinite number of elements, the program terminates after five squares because the view stops early.

#include <ranges>
#include <iostream>

int main() {
    // Infinite iota, square each, take first five elements.
    auto rng = std::views::iota(0)               // 0,1,2,... infinite
               | std::views::transform([](int x){ return x * x; })
               | std::views::take(5);
    for (int v : rng) {
        std::cout << v << ' ';
    }
    std::cout << '\n';
}

The output contains the first five squares 0 1 4 9 16. In contrast, a pre-ranges algorithm that first copies the iota range into a std::vector allocates billions of elements before the program must stop. The lazy view avoids that allocation entirely, saving both time and memory.

Laziness also improves cache behaviour. Each element is produced, transformed, and consumed in a single pass, keeping data in registers and avoiding a second memory pass. The same laziness applies to conditional stops: views::take_while keeps elements while a predicate holds and then halts, and views::drop_while discards until the predicate first fails. Because the adaptors are stateless, chaining drop_while with take_while on a sorted range extracts a contiguous band in one pass without building it.

Materialising a view: std::ranges::to

Sometimes code needs ownership of the elements produced by a view. The helper std::ranges::to materialises a view into a concrete container. The syntax is view | std::ranges::to<Container>(). The container type must be default-constructible and support push_back or equivalent insertion.

Materialisation is useful when an algorithm later requires random access, when the data must outlive the original source, or when an API expects an owning container. The operation copies each element exactly once and respects the allocator of the target container.

The target need not be a std::vector. std::ranges::to accepts any container meeting the insertion requirements, including std::list, std::deque, or a fixed-size std::array when the size is known. An optional allocator argument forwards to the container constructor, so materialisation can use a custom pool without changing the pipeline.

The following program creates the view iota(1,6), materialises it into a std::vector, and prints the size of the vector.

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

int main() {
    auto rng = std::views::iota(1, 6); // 1,2,3,4,5
    auto vec = rng | std::ranges::to<std::vector<int>>();
    std::cout << "size = " << vec.size() << '\n';
}

The program prints size = 5, confirming that the view was copied into a container of the requested size.

Projections everywhere

Most range adaptors and algorithms accept a projection, a callable that extracts a member from each element. Projections let you avoid writing a lambda for a common operation. The syntax &T::member is a pointer-to-member that the algorithm treats as a projection.

Projections improve compile-time readability and often enable better inlining because the compiler sees a direct member access instead of a generic lambda capture. They also integrate with concepts that require a std::indirectly_readable predicate. This lets the standard library reason about the operation without executing user code.

The example below defines a simple Point struct with members x and y. A std::vector<Point> is sorted by the x coordinate using the projection &Point::x. The program then prints the sorted x values.

#include <algorithm>
#include <iostream>
#include <vector>

struct Point {
    int x;
    int y;
};

int main() {
    std::vector<Point> pts{{3,5},{1,2},{2,4}};
    // Sort by x using a projection
    std::ranges::sort(pts, {}, &Point::x);
    for (const auto& p : pts) {
        std::cout << p.x << ' ';
    }
    std::cout << '\n';
}

The output 1 2 3 demonstrates that the projection eliminated the need for a custom comparator.

Beyond sorting, any algorithm that accepts a projection can use the member‑pointer form. std::ranges::find(v, value, &T::key) searches a range of objects for a specific key without a bespoke lambda. The same member‑pointer projection works on associative ranges through views::values and views::keys. For example, m | std::views::values yields the integers from a std::map<std::string, int> directly. These patterns appear throughout the standard library and eliminate boilerplate code.

std::ranges algorithms versus pre-ranges algorithms

The range algorithms in std::ranges operate directly on any range, including views. They return iterators that refer to the original elements, so no copying occurs. The pipe syntax composes algorithms with adaptors. This makes the intent clear and the code compact.

The pre-ranges algorithmic style often required explicit iterator arguments, temporary containers, and separate calls to std::find or std::count. The range version can operate on a filtered view without materialising the intermediate sequence.

The next program filters a std::vector<int> for even numbers and then counts them using std::ranges::count_if. The count is printed.

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

int main() {
    std::vector<int> data{1,2,3,4,5,6};
    auto evens = data | std::views::filter([](int x){ return x % 2 == 0; });
    auto count = std::ranges::count_if(evens, [](int){ return true; }); // count elements in view
    std::cout << "evens = " << count << '\n';
}

The expected output evens = 3 shows that the algorithm works on the filtered view without materialising a new container.

Range algorithms also accept a projection argument, mirroring the adaptor behaviour. For example, std::ranges::sort(v, {}, &T::member) sorts a range of structures by a field without an explicit comparator lambda. This uniformity simplifies generic code that works with both plain values and aggregates.

Reduction joins the model as well. std::ranges::fold_left and std::ranges::fold_right accumulate a range with an initial value and a binary operation, returning the combined result rather than writing into a destination. They pair naturally with a filter and a transform, so the filter-map-reduce pattern from the algorithms chapter becomes one expression.

Custom views and std::range_adaptor_closure

Advanced users can create reusable pipelines by defining a range adaptor closure. A closure is a callable that returns a view when applied to a range. The closure can be used in a pipe expression just like the built-in adaptors.

Defining a closure typically involves a generic lambda that captures any configuration parameters and returns a composition of existing adaptors. Because the closure returns a closure object (std::range_adaptor_closure), the compiler treats it as another adaptor, preserving laziness.

The example below defines a simple adaptor twice that multiplies each element by two. The adaptor is expressed as a generic lambda that returns a transform view. The program creates an iota range, pipes it through twice, and prints the results.

#include <ranges>
#include <iostream>

// A reusable, pipeable adaptor that doubles each element.
struct Twice : std::ranges::range_adaptor_closure<Twice> {
    template <std::ranges::viewable_range R>
    auto operator()(R&& r) const {
        return std::views::transform(std::forward<R>(r), [](int x) { return x * 2; });
    }
};

inline constexpr Twice twice;

int main() {
    auto rng = std::views::iota(1, 5) | twice;  // 1..4 doubled -> 2 4 6 8
    for (int v : rng) {
        std::cout << v << ' ';
    }
    std::cout << '\n';
}

The output 2 4 6 8 confirms that the custom adaptor behaved like a built-in view. Readers can extend this pattern to more sophisticated pipelines, such as a prime_filter that composes filter with a deterministic primality test. Because the closure is a compile-time object, the optimizer can erase the intermediate layers entirely.

A view lives only as long as its source. Returning a view to a local container or temporary yields a dangling view, which the compiler warns about (see chapter 07). Because the pipeline composes at compile time, the compiler can collapse multiple adaptor layers into a single loop, achieving speed comparable to hand‑written loops while preserving readability.

Try this

Build a pipeline that takes std::views::iota(1, n), keeps only the multiples of 3, squares each kept value, takes the first 5 results, and prints them. The program must compile with the book’s standard settings and must run without allocating an intermediate container.

No solution is provided. The reader must write the code, register it as a book_example, and verify that the output contains five numbers.