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++20A 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:
- Range concepts — a constraint hierarchy that replaces raw iterator pairs in algorithm signatures.
- Range algorithms — all classic
<algorithm>functions restated to accept ranges directly, with projection support. - View adaptors — lazy, composable transformations in
std::views::that chain with|.
Range concept hierarchy
| Concept | Additional guarantee over parent |
|---|---|
std::ranges::range | begin() + end() |
std::ranges::input_range | single-pass iteration |
std::ranges::forward_range | multi-pass, equality-comparable iterators |
std::ranges::bidirectional_range | operator-- |
std::ranges::random_access_range | O(1) operator[] and iterator arithmetic |
std::ranges::contiguous_range | elements in contiguous memory |
std::ranges::sized_range | O(1) size() |
std::ranges::common_range | begin() and end() have the same type |
std::ranges::borrowed_range | iterators remain valid after the range is destroyed |
std::ranges::view | O(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.
// 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:
// 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); // hellostd::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:
// 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)
#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, 36Generating views (C++20)
// 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 rangeTuple-element views (C++20)
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 columnFlattening and splitting (C++20)
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
// 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++23Range algorithms with projections (C++20)
Projections apply a function before the comparator, eliminating wrapper lambdas:
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)
// 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():
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++23Best 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:
// 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:
auto filtered = data
| std::views::filter(expensive_check)
| std::ranges::to<std::vector>(); // C++23 — pay once, iterate freelyUse 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:
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 — OKThis 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:
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 occurredThe 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:
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 implicitlystd::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:
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:
// 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.
// 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, 18Use 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.
// 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.
// 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.
// 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.
// 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:
// 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).
// 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 triplescartesian_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:
// 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++23If 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:
// 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 resultsRe-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:
// 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 positionUntil 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_rangeview over contiguous memory (C++20)std::string_view—borrowed_rangeover 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 havestd::ranges::equivalents (C++20)