Skip to content
C++
Library
since C++20
Intermediate

Ranges & Views — Complete Reference

"C++20 ranges and views: range concepts, lazy view adaptors, composable pipelines, projections, and the pitfalls that bite experienced engineers."

Ranges & Viewssince C++20

A range is any type providing begin()/end(); a view is a lightweight, non-owning range adaptor with O(1) copy/move that transforms or filters elements lazily without materializing intermediate containers.

Overview

The <ranges> header (C++20) unifies three previously separate concerns:

  1. Range concepts — a constraint hierarchy that replaces raw iterator pairs in algorithm signatures.
  2. Range algorithms — all classic <algorithm> functions restated to accept ranges directly, with projection support.
  3. View adaptors — lazy, composable transformations in std::views:: that chain with |.

Range concept hierarchy

ConceptAdditional guarantee over parent
std::ranges::rangebegin() + end()
std::ranges::input_rangesingle-pass iteration
std::ranges::forward_rangemulti-pass, equality-comparable iterators
std::ranges::bidirectional_rangeoperator--
std::ranges::random_access_rangeO(1) operator[] and iterator arithmetic
std::ranges::contiguous_rangeelements in contiguous memory
std::ranges::sized_rangeO(1) size()
std::ranges::common_rangebegin() and end() have the same type
std::ranges::borrowed_rangeiterators remain valid after the range is destroyed
std::ranges::viewO(1) copy/move/destroy

These distinctions are load-bearing: std::views::reverse requires bidirectional_range; random_access_range enables operator[] on the adapted result; borrowed_range determines whether an algorithm can safely return an iterator into a range you passed by value.

cpp
// C++20
static_assert(std::ranges::contiguous_range<std::vector<int>>);
static_assert(std::ranges::bidirectional_range<std::list<int>>);
static_assert(!std::ranges::random_access_range<std::list<int>>);

template<std::ranges::forward_range R>
void process(R&& r) { /* guaranteed multi-pass */ }

Sentinel types

C++20 decouples the end-of-range sentinel from the iterator type. Begin and end no longer need to share a type — enabling null-terminated strings, infinite generators, and protocol-delimited streams:

cpp
// C++20 — heterogeneous begin/end types
struct NullSentinel {};
struct CharPtr {
    const char* p;
    using difference_type   = std::ptrdiff_t;
    using value_type        = char;
    using iterator_category = std::input_iterator_tag;
    char       operator*()  const { return *p; }
    CharPtr&   operator++()       { ++p; return *this; }
    bool operator==(NullSentinel) const { return *p == '\0'; }
};

const char* cstr = "hello";
auto r = std::ranges::subrange(CharPtr{cstr}, NullSentinel{});
for (char c : r) std::print("{}", c);  // hello

std::default_sentinel_t is the standard tag for this pattern — use it in your own types instead of inventing a new sentinel.


Syntax

Pipe operator and range adaptor objects

std::views::filter, std::views::transform, and all other adaptors are range adaptor objects — callable objects, not function templates. The | operator passes the left-hand range as the first argument:

cpp
// These are equivalent — C++20
auto a = std::views::filter(v, pred);
auto b = v | std::views::filter(pred);

// Partial application: adaptor minus the range produces a closure
auto drop3 = std::views::drop(3);    // closure, not a view
auto result = v | drop3;             // applied later

// Closures compose
auto pipeline = std::views::filter(is_even) | std::views::transform(square);
auto r = v | pipeline;  // equivalent to v | filter(...) | transform(...)

Examples

Core view adaptors (C++20)

cpp
#include <ranges>
#include <vector>

std::vector v{1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

auto evens   = v | std::views::filter([](int n) { return n % 2 == 0; });   // C++20
auto squares = v | std::views::transform([](int n) { return n * n; });     // C++20
auto first5  = v | std::views::take(5);                                    // C++20
auto skip3   = v | std::views::drop(3);                                    // C++20
auto rev     = v | std::views::reverse;   // requires bidirectional_range  // C++20

// Composable — nothing materialised until iterated
auto result = v
    | std::views::filter([](int n) { return n % 2 == 0; })
    | std::views::transform([](int n) { return n * n; })
    | std::views::take(3);
// Lazily yields: 4, 16, 36

Generating views (C++20)

cpp
// iota — arithmetic sequence, bounded or infinite
for (int i : std::views::iota(1, 6)) std::print("{} ", i);  // 1 2 3 4 5

auto naturals = std::views::iota(0);                   // infinite
auto first10  = naturals | std::views::take(10);       // safe — lazy

// counted — raw pointer or iterator + count
int arr[] = {10, 20, 30, 40, 50};
auto slice = std::views::counted(arr, 3);              // {10, 20, 30}

// single / empty
auto one = std::views::single(42);                     // range of one element
auto nil = std::views::empty<int>;                     // empty typed range

Tuple-element views (C++20)

cpp
std::map<std::string, int> scores{{"Alice",95},{"Bob",82}};
auto names  = scores | std::views::keys;    // C++20
auto values = scores | std::views::values;  // C++20

// elements<N> — Nth field of any tuple-like element  (C++20)
std::vector<std::tuple<int, std::string, double>> rows{
    {1, "alpha", 3.14}, {2, "beta", 2.71}
};
auto ids = rows | std::views::elements<0>;  // int column

Flattening and splitting (C++20)

cpp
std::vector<std::vector<int>> nested{{1,2},{3,4},{5,6}};
for (int n : nested | std::views::join)       // C++20
    std::print("{} ", n);  // 1 2 3 4 5 6

// join_with — insert delimiter between inner ranges (C++23)
for (int n : nested | std::views::join_with(0))
    std::print("{} ", n);  // 1 2 0 3 4 0 5 6

// split — tokenize on delimiter (C++20)
// Note: each element is a subrange<iterator>, not a string_view — see Pitfalls
std::string_view csv = "2024,01,15";
for (auto part : csv | std::views::split(','))
    std::println("{}", std::string_view{part.begin(), part.end()});

C++23 view adaptors

cpp
// enumerate — (index, element) pairs
for (auto [i, name] : std::views::enumerate(names))  // C++23
    std::println("{}: {}", i, name);

// zip — multiple ranges in lockstep
for (auto [n, s] : std::views::zip(ints, strings))   // C++23
    std::println("{} = {}", n, s);

// chunk — non-overlapping fixed-size windows
for (auto w : std::views::iota(1,8) | std::views::chunk(3))  // C++23
    std::println("{}", w);  // [1,2,3], [4,5,6], [7]

// slide — overlapping windows
for (auto w : std::views::iota(1,6) | std::views::slide(3))  // C++23
    std::println("{}", w);  // [1,2,3], [2,3,4], [3,4,5]

// stride — every Nth element
for (int n : std::views::iota(0,10) | std::views::stride(3))  // C++23
    std::print("{} ", n);  // 0 3 6 9

// cartesian_product — all N-ary combinations
for (auto [a, b] : std::views::cartesian_product(             // C++23
    std::views::iota(0,3), std::views::iota(0,2)))
    std::print("({},{}) ", a, b);  // (0,0)(0,1)(1,0)(1,1)(2,0)(2,1)

// repeat — infinite or bounded repetition
auto fives = std::views::repeat(5) | std::views::take(4);     // C++23

Range algorithms with projections (C++20)

Projections apply a function before the comparator, eliminating wrapper lambdas:

cpp
struct Employee { std::string name; int salary; int dept; };
std::vector<Employee> staff{
    {"Alice", 95000, 2}, {"Bob", 72000, 1}, {"Carol", 88000, 2}
};

std::ranges::sort(staff, std::greater{}, &Employee::salary);  // by salary desc

auto it = std::ranges::find(staff, 2, &Employee::dept);       // first in dept 2

long high = std::ranges::count_if(staff,
    [](int s){ return s > 80000; }, &Employee::salary);

auto lowest = std::ranges::min_element(staff, {}, &Employee::salary);

Every std::ranges:: algorithm accepts a projection as a trailing argument — sort, find, min_element, unique, partition, and more.

Collecting into containers (C++23) and fold (C++23)

cpp
// std::ranges::to — C++23
auto squares = std::views::iota(1, 6)
    | std::views::transform([](int n){ return n * n; })
    | std::ranges::to<std::vector>();  // {1, 4, 9, 16, 25}

// Pre-C++23 workaround
auto r = std::views::iota(1, 6) | std::views::transform([](int n){ return n*n; });
std::vector<int> v(r.begin(), r.end());

// std::ranges::fold_left — replaces std::accumulate (C++23)
auto sum = std::ranges::fold_left(
    std::views::iota(1, 6), 0, std::plus{});  // 15

// fold_left_first — no initial value, returns optional (C++23)
auto product = std::ranges::fold_left_first(
    std::vector{2, 3, 4}, std::multiplies{});  // optional{24}

Implementing a custom view (C++20)

Inherit from std::ranges::view_interface<Derived> to get empty(), front(), back(), operator[], and size() automatically derived from begin()/end():

cpp
struct FibView : std::ranges::view_interface<FibView> {  // C++20
    int limit;
    explicit FibView(int n) : limit(n) {}

    struct Sentinel {};
    struct Iterator {
        int a = 0, b = 1, limit;
        using difference_type   = std::ptrdiff_t;
        using value_type        = int;
        using iterator_category = std::input_iterator_tag;

        int        operator*()    const { return a; }
        Iterator&  operator++()         { int t=a+b; a=b; b=t; return *this; }
        Iterator   operator++(int)      { auto tmp=*this; ++*this; return tmp; }
        bool operator==(Sentinel) const { return a > limit; }
    };

    Iterator begin() const { return {.limit=limit}; }
    Sentinel end()   const { return {}; }
};

static_assert(std::ranges::input_range<FibView>);
static_assert(std::ranges::view<FibView>);

// Composes with all view adaptors
auto big = FibView{1000}
    | std::views::filter([](int n){ return n > 10; })
    | std::ranges::to<std::vector>();  // C++23

Best Practices

Prefer projections over a transform-then-compare pipeline. ranges::sort(v, {}, &T::field) avoids a wrapping view and a heap allocation.

Use auto&& in range-for over view pipelines. Views such as zip_view return proxy references (tuples of references), and auto [k, v] by value copies instead of binding:

cpp
// Correct — binds references through proxy
for (auto&& [k, v] : map | std::views::filter(/* ... */)) { k; v; }

Materialize when you iterate more than once. filter_view re-runs the predicate on every pass. For expensive predicates over a range you traverse repeatedly, collect first:

cpp
auto filtered = data
    | std::views::filter(expensive_check)
    | std::ranges::to<std::vector>();  // C++23 — pay once, iterate freely

Use std::ranges::subrange to wrap iterator pairs for passing into range-aware APIs when you have legacy begin/end iterators from a non-range API.


Common Pitfalls

filter_view and reverse_view are not const-iterable

filter_view caches its begin() result on first call (for amortized O(1) start). The cache write makes begin() non-const, so const-qualifying the view breaks iteration:

cpp
const auto view = data | std::views::filter(pred);
for (int n : view) { }  // compile error — begin() is not const

auto view2 = data | std::views::filter(pred);  // mutable — OK

This also prevents passing filter_view to any function taking const Range& that then iterates it.

View invalidation after container mutation

A stored view becomes dangling if the underlying container invalidates its iterators. With std::vector, any push_back that triggers reallocation invalidates all iterators held by views into it:

cpp
std::vector<int> v{1,2,3,4,5};
auto view = v | std::views::drop(2);  // holds iterators into v

v.push_back(6);   // may reallocate
for (int n : view) { }  // UB if reallocation occurred

The fix: either re-compute the view after mutation, or use reserve() to prevent reallocation.

Dangling iterators from temporaries

std::ranges algorithms detect rvalue non-borrowed_range arguments and return std::ranges::dangling — a type that does not dereference — instead of an iterator:

cpp
auto it = std::ranges::find(std::vector{1,2,3}, 2);
*it;  // compile error — std::ranges::dangling has no operator*

std::vector v{1,2,3};
auto it2 = std::ranges::find(v, 2);  // OK — lvalue is borrowed implicitly

std::string_view, std::span, and std::ranges::subrange are borrowed_ranges; their iterators are always safe to return.

split_view elements are subranges, not string_views

Each element produced by views::split is a subrange<iterator>, not a string_view. You must construct explicitly:

cpp
for (auto part : sv | std::views::split(',')) {
    // C++20: manual construction
    auto token = std::string_view{part.begin(), part.end()};
    // C++23: range constructor
    auto token2 = std::string_view{part};
}

Infinite views passed to non-terminating algorithms

There is no compile-time guard against passing an infinite view to an algorithm that doesn't terminate on such input:

cpp
// HANGS — iota(0) is infinite; -1 never appears
auto it = std::ranges::find(std::views::iota(0), -1);

// Safe — bound with take
auto it2 = std::ranges::find(
    std::views::iota(0) | std::views::take(10'000), -1);

C++23 Views In Depth

C++23 promoted a large batch of range adaptors from range-v3 into the standard library. The adaptors below fill the most common gaps left by the C++20 set: pairing elements across ranges, windowing, grouping, and indexing.

views::zip and views::zip_transform

views::zip combines N ranges element-by-element, yielding tuples of references. The resulting view terminates when the shortest input range is exhausted, so the ranges need not have equal length.

cpp
// C++23
#include <ranges>
#include <vector>
#include <string>
#include <print>

std::vector<int>         ids   {1, 2, 3, 4};
std::vector<std::string> names {"Alice", "Bob", "Carol"};

// zip stops at shortest range — produces 3 elements here
for (auto&& [id, name] : std::views::zip(ids, names))
    std::println("{}: {}", id, name);
// 1: Alice
// 2: Bob
// 3: Carol

// zip_transform applies a binary function instead of yielding a tuple
auto dot = std::views::zip_transform(
    std::multiplies{},
    std::vector{1, 2, 3},
    std::vector{4, 5, 6}
);
// lazily yields: 4, 10, 18

Use auto&& in structured bindings over zip_view; the elements are proxy tuple-of-references, and a bare auto [a, b] would copy rather than bind.

views::enumerate

views::enumerate pairs each element with its zero-based index, replacing the classic "index variable in a range-for" pattern and making the intent explicit.

cpp
// C++23
std::vector<std::string> words{"zero", "one", "two", "three"};

for (auto&& [i, word] : std::views::enumerate(words))
    std::println("[{}] {}", i, word);
// [0] zero
// [1] one
// [2] two
// [3] three

// Composable — enumerate after filter preserves original gap-free indices
auto indexed_evens = std::views::iota(0, 10)
    | std::views::filter([](int n){ return n % 2 == 0; })
    | std::views::enumerate;
// yields (0,0), (1,2), (2,4), (3,6), (4,8)

The index type is std::ranges::range_difference_t<R>, not size_t — it is a signed integer, which avoids unsigned-wrap bugs in subtraction expressions.

views::chunk and views::slide

views::chunk(n) partitions a range into consecutive non-overlapping windows of size n, while views::slide(n) produces every overlapping window of size n. Use chunk for batching and slide for rolling-window statistics.

cpp
// C++23
auto src = std::views::iota(1, 9);  // 1..8

// chunk — non-overlapping fixed-size groups
for (auto window : src | std::views::chunk(3))
    std::println("{}", window);
// [1, 2, 3]
// [4, 5, 6]
// [7, 8]        ← last window may be smaller

// slide — every overlapping sub-window of width 3
for (auto window : src | std::views::slide(3))
    std::println("{}", window);
// [1, 2, 3]
// [2, 3, 4]
// [3, 4, 5]
// [4, 5, 6]
// [5, 6, 7]
// [6, 7, 8]

slide requires a forward_range and yields exactly size(r) - n + 1 windows (or zero if size(r) < n). chunk only requires an input_range, so it works on single-pass sources such as network streams or coroutine generators.

views::chunk_by

views::chunk_by groups consecutive elements that satisfy a binary predicate, producing a view-of-subranges where each subrange holds a run of "equal" elements under the predicate. It is the lazy counterpart to std::ranges::chunk_by.

cpp
// C++23
std::vector<int> data{1, 1, 2, 2, 2, 1, 3, 3};

// group runs where adjacent elements are equal
for (auto run : data | std::views::chunk_by(std::equal_to{}))
    std::println("{}", run);
// [1, 1]
// [2, 2, 2]
// [1]
// [3, 3]

// Custom predicate — group while ascending
for (auto run : data | std::views::chunk_by(std::less_equal{}))
    std::println("{}", run);
// [1, 1, 2, 2, 2]
// [1, 3, 3]

The predicate receives adjacent elements (a, b) and should return true if they belong to the same chunk. Only forward_range or stronger is required.


views::cartesian_product — Replacing Nested Loops

views::cartesian_product generates every N-ary combination of elements from N input ranges, yielding a view of tuples in row-major order. It eliminates manually nested loops for combinatorial enumeration without materialising an intermediate container.

cpp
// C++23
#include <ranges>
#include <print>

// Two ranges — all (row, col) pairs for a 3×2 grid
auto rows = std::views::iota(0, 3);
auto cols = std::views::iota(0, 2);

for (auto [r, c] : std::views::cartesian_product(rows, cols))
    std::print("({},{}) ", r, c);
// (0,0) (0,1) (1,0) (1,1) (2,0) (2,1)

cartesian_product generalises to any number of ranges:

cpp
// C++23
std::string_view suits = "CDHS";
std::string_view ranks = "A23456789TJQK";

// 52-card deck — all (suit, rank) pairs
for (auto [s, r] : std::views::cartesian_product(suits, ranks))
    std::print("{}{} ", s, r);

The view's size is the product of the input sizes, computed lazily — no temporary storage is allocated. Iteration order is determined by the last range cycling fastest (C-order / row-major).

cpp
// C++23 — three-range product for coordinate triples
auto xs = std::views::iota(0, 2);
auto ys = std::views::iota(0, 2);
auto zs = std::views::iota(0, 2);

auto coords = std::views::cartesian_product(xs, ys, zs)
    | std::views::filter([](auto t){
          auto [x, y, z] = t;
          return x + y + z <= 2;
      })
    | std::ranges::to<std::vector>();  // C++23 — materialise filtered triples

cartesian_product requires all input ranges to be at least forward_range; it is sized_range when all inputs are sized, and random_access_range when all inputs are random-access.


Lazy Evaluation and Caching

Views are lazy by design — elements are computed on demand as iterators advance, not when the pipeline is constructed. This means transformation functions run each time an iterator is dereferenced, which has important consequences for side effects, repeated evaluation, and const-correctness.

Why views::transform with side effects is problematic

Each dereference of a transform_view iterator re-invokes the transform function. Algorithms that call operator* more than once per element — such as std::ranges::find, which may re-evaluate to check equality — can invoke the function multiple times unexpectedly:

cpp
// C++20 — trap: transform function runs more than once per element
int call_count = 0;
auto logged = std::views::iota(0, 5)
    | std::views::transform([&](int n){
          ++call_count;
          return n * n;
      });

// A naive algorithm may dereference the same iterator position multiple times
for (auto it = logged.begin(); it != logged.end(); ++it) {
    auto val = *it;  // call 1
    (void)*it;       // call 2 — same iterator, function fires again
}

// Safe pattern: materialise first, then work with concrete values
auto squares = logged | std::ranges::to<std::vector>();  // C++23

If your transform function has observable side effects (logging, I/O, counters), materialise the view before iterating to guarantee exactly-once evaluation.

filter_view caching of begin()

filter_view caches its begin() iterator after the first call to amortise the O(n) scan for the first matching element. The cache is mutable state inside the view object, which is why filter_view::begin() is not const and cannot be called on a const-qualified view. It also means that if the underlying range changes between two passes, the cached begin iterator may be stale:

cpp
// C++20
std::vector<int> v{1, 3, 2, 4, 6};
auto evens = v | std::views::filter([](int n){ return n % 2 == 0; });

// First iteration — begin() scans, caches position of '2'
for (int n : evens) std::print("{} ", n);  // 2 4 6

// Mutate source
v[2] = 99;  // was '2', now '99'

// Second iteration — begin() returns cached iterator, skips new front
// Behaviour is undefined if the view was borrowed from a reallocated container
for (int n : evens) std::print("{} ", n);  // UB / surprising results

Re-construct the view after any mutation that changes which element begin() should point to.

views::cache_latest (range-v3 / proposal)

The C++ standard does not yet include a cache_latest adaptor, but it is available in range-v3 and is proposed for a future standard. It caches the most recently dereferenced element, ensuring that repeated dereferences of the same iterator position invoke the underlying function only once:

cpp
// range-v3 — not yet std::, but the pattern is instructive
#include <range/v3/view/cache1.hpp>

auto expensive_transform = my_range
    | ranges::views::transform(costly_function)
    | ranges::views::cache1;  // every *it returns the cached value

// After cache1, it is safe to call *it multiple times per position

Until cache_latest lands in the standard, the pragmatic C++23 workaround is to materialise the transformed range into a std::vector with std::ranges::to, then iterate the concrete container.


See Also

  • std::span — borrowed_range view over contiguous memory (C++20)
  • std::string_view — borrowed_range over character sequences (C++17)
  • std::ranges::subrange — wraps a begin/sentinel pair into a range (C++20)
  • std::generator — coroutine-based lazy range source (C++23)
  • <algorithm> — all standard algorithms have std::ranges:: equivalents (C++20)