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);

See Also

  • std::spanborrowed_range view over contiguous memory (C++20)
  • std::string_viewborrowed_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)