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

Ranges (C++20)

"C++20's ranges library: constrained algorithms, lazy view pipelines, and composable adaptors that replace raw iterator-pair APIs."

Rangessince C++20

The ranges library unifies any type exposing begin()/end() under a concept hierarchy, adds constrained algorithm overloads in std::ranges::, and introduces views β€” lazily-evaluated, composable range adaptors combined with the | pipe operator.

Overview

The range concept hierarchy

<ranges> defines a refinement chain of concepts that describe what operations a range supports:

ConceptAdds over previous
std::ranges::range<R>begin() + end() (minimum)
std::ranges::input_range<R>single-pass readable elements
std::ranges::forward_range<R>multi-pass, copyable iterators
std::ranges::bidirectional_range<R>-- on iterators
std::ranges::random_access_range<R>+, -, [] on iterators
std::ranges::contiguous_range<R>elements contiguous in memory
std::ranges::sized_range<R>std::ranges::size(r) is O(1)
std::ranges::borrowed_range<R>iterators remain valid after range is destroyed

Algorithm overloads are constrained by these concepts. std::ranges::sort requires random_access_range + sortable; std::ranges::find needs only input_range. Violations are caught at compile time with concept-backed diagnostics.

Sentinels

Ranges generalise the begin/end pair: the sentinel (end marker) need not share the iterator's type. This enables zero-overhead termination:

cpp
#include <ranges>
#include <cstring>

// Iterate exactly N elements from a raw pointer β€” no strlen
const char* p = "hello world";
for (char c : std::views::counted(p, 5)) { /* 'h','e','l','l','o' */ }  // C++20

// Unbounded range with unreachable_sentinel β€” compiler assumes end is never hit
for (int n : std::ranges::subrange(p, std::unreachable_sentinel)) { // C++20
    if (*p == '\0') break;
}

std::unreachable_sentinel (C++20) lets the optimiser elide the end-check entirely, producing the same code as a hand-written pointer loop.

Syntax

Range algorithm overloads

Every <algorithm> function has a constrained counterpart in std::ranges::. Key differences:

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

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

// C++20: take a range directly; no begin()/end() ceremony
std::ranges::sort(v);
std::ranges::sort(v, std::greater{});

// C++20: projections β€” callable applied to each element before comparison
std::ranges::sort(v, {}, [](int n){ return -n; });   // sort by negation

// C++20: richer return types
auto [lo, hi] = std::ranges::minmax_element(v);      // pair of iterators

// C++20: remove returns a subrange of the tail, not a single iterator
auto tail = std::ranges::remove(v, 1);
v.erase(tail.begin(), tail.end());                   // erase-remove still needed

View pipeline syntax

cpp
namespace views = std::views;   // std::ranges::views β€” C++20 alias

auto result = source_range
    | views::filter(pred)          // std::ranges::filter_view
    | views::transform(func)       // std::ranges::transform_view
    | views::take(n);              // std::ranges::take_view

Each | constructs a new view type wrapping the previous one. No iteration occurs until begin() is called on the outermost view. The entire chain is evaluated in a single pass over the source.

Examples

Realistic pipeline: structured filtering

cpp
#include <ranges>
#include <vector>
#include <print>

struct LogEntry {
    int         level;      // 0=debug … 4=critical
    long long   timestamp;
    std::string message;
};

std::vector<LogEntry> logs = get_logs();
long long now = current_epoch_seconds();

// C++20: errors in last hour, most recent first, capped at 20 β€” zero allocations
auto recent_errors = logs
    | std::views::filter([](const LogEntry& e){ return e.level >= 3; })
    | std::views::filter([now](const LogEntry& e){ return now - e.timestamp < 3600; })
    | std::views::reverse
    | std::views::take(20);

for (const auto& e : recent_errors) {
    std::println("[{}] {}", e.timestamp, e.message);  // C++23 println
}

Materialising a view into a container

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

// C++23: std::ranges::to β€” clearest and handles nested ranges correctly
auto evens = v
    | std::views::filter([](int n){ return n % 2 == 0; })
    | std::ranges::to<std::vector>();              // C++23

// C++20: explicit copy via back_inserter
std::vector<int> evens20;
std::ranges::copy(
    v | std::views::filter([](int n){ return n % 2 == 0; }),
    std::back_inserter(evens20)
);

// C++20: range constructor
auto r = v | std::views::filter([](int n){ return n % 2 == 0; });
std::vector<int> evens20b(r.begin(), r.end());

Projections eliminate custom comparators

cpp
struct Employee {
    std::string name;
    int         department;
    double      salary;
};

std::vector<Employee> staff = load_employees();

// C++20: sort by salary descending β€” no lambda comparator needed
std::ranges::sort(staff, std::greater{}, &Employee::salary);

// C++20: find first employee in department 3
auto it = std::ranges::find(staff, 3, &Employee::department);

// C++20: stable partition by department, projecting to int field
auto [dept3, rest] = std::ranges::partition(staff,
    [](int d){ return d == 3; }, &Employee::department);

Projections bind to any callable or member pointer and are applied before the comparator, eliminating the most common reason to write a custom comparator.

Infinite sequences and generators

cpp
// C++20: infinite square sequence β€” terminated by take
auto squares = std::views::iota(1)                         // 1, 2, 3, ...
    | std::views::transform([](int n){ return n * n; });   // 1, 4, 9, ...

for (int s : squares | std::views::take(5)) {
    std::print("{} ", s);   // 1 4 9 16 25
}

// C++20: every third index, squared β€” single-pass, no temporaries
for (int v : std::views::iota(0, 30)
               | std::views::filter([](int n){ return n % 3 == 0; })
               | std::views::transform([](int n){ return n * n; })) {
    std::print("{} ", v);   // 0 9 36 81 144 225 324 441 576 625
}

C++23 multi-range adaptors

cpp
#include <ranges>

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

// views::zip (C++23) β€” iterate multiple ranges in lockstep
for (auto [id, name] : std::views::zip(ids, names)) {
    std::println("{}: {}", id, name);
}

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

// views::chunk (C++23) β€” non-overlapping fixed-size windows
for (auto chunk : ids | std::views::chunk(2)) { /* {1,2}, {3} */ }

// views::slide (C++23) β€” overlapping windows
for (auto w : std::views::iota(1, 6) | std::views::slide(3)) {
    // {1,2,3}, {2,3,4}, {3,4,5}
}

Best Practices

Prefer std::ranges:: algorithms unconditionally. They require no begin()/end() boilerplate, accept projections, return richer types (subrange over raw iterator), and diagnose misuse at compile time through concepts.

Alias the namespace at function scope. namespace views = std::views; removes most of the verbosity. namespace rv = std::ranges::views; is an alternative favoured in some codebases.

Use std::ranges::to<std::vector>() (C++23) to materialise. It expresses intent and handles nested range types (e.g. split) correctly through the range constructor pathway.

Never store a view as a class member unless the underlying data outlives it. Views are non-owning. If the source container is moved or reallocated, any stored view produces undefined behaviour on iteration.

Compute distance before iterating, not during. std::ranges::distance on a filter_view is O(n). If you need both the count and the elements, materialise the range once.

Common Pitfalls

Dangling views from temporaries

cpp
// UNDEFINED BEHAVIOUR β€” underlying vector is destroyed before the view is iterated
auto bad() {
    std::vector<int> v = {1, 2, 3, 4};
    return v | std::views::filter([](int n){ return n > 1; });
}   // v destroyed here; caller iterates dangling memory

The type system catches the return-of-algorithm-result case: if you call std::ranges::find with a temporary (non-borrowed_range), the return type is std::ranges::dangling β€” a non-dereferenceable placeholder that prevents the iterator from being used.

cpp
auto it = std::ranges::find(std::vector{1, 2, 3}, 2);
// decltype(it) is std::ranges::dangling β€” dereferencing is a compile error

filter_view caches begin() β€” beware mutation

filter_view (and drop_while_view) cache their begin() iterator for amortised O(1) access on bidirectional ranges. This has two consequences:

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

auto it = evens.begin();   // begin() computed and cached

v.insert(v.begin(), 0);    // reallocates v β€” cached iterator is now invalid; UB on next use

Additionally, filter_view is not const-iterable β€” you cannot call begin() on a const filter_view. This makes it incompatible with functions that take const auto&.

Piping through filter drops random-access

cpp
std::vector<int> v = {3, 1, 4, 1, 5};
auto r = v | std::views::filter([](int n){ return n > 2; });

std::ranges::sort(r);   // compile error: filter_view is bidirectional, not random_access

Materialise into a vector before sorting filtered results.

Legacy std:: algorithms require a common range

Algorithms in std:: (not std::ranges::) require iterator and sentinel to share the same type. Most bounded views satisfy this, but views::iota(0) (unbounded) does not. Wrap with views::common when you must use a legacy algorithm β€” or better, switch to the std::ranges:: overload:

cpp
auto r = std::views::iota(0, 10) | std::views::filter([](int n){ return n % 3 != 0; });

// Legacy β€” may fail if sentinel type differs from iterator type
// std::copy(r.begin(), r.end(), out);

// Fix option A: views::common adapter (C++20)
std::copy((r | std::views::common).begin(), (r | std::views::common).end(), out);

// Fix option B: use the constrained algorithm (preferred)
std::ranges::copy(r, out);

O(n) distance masquerading as O(1)

cpp
auto r = large_vec | std::views::filter(expensive_pred);
if (std::ranges::distance(r) > 100) {  // iterates the ENTIRE range β€” O(n)
    process(r);                          // then iterates AGAIN
}

Restructure to std::ranges::count_if (single pass) or materialise once:

cpp
auto results = large_vec | std::views::filter(expensive_pred)
                         | std::ranges::to<std::vector>();  // C++23, single pass
if (results.size() > 100) { process(results); }

Range Adaptor Chaining

Pipelines are just nested view constructors. Each | operator creates a new view type wrapping the previous one; the full expression is resolved entirely at compile time with zero virtual dispatch.

cpp
// C++20
#include <ranges>
#include <vector>
#include <string>

struct Product {
    std::string name;
    int         category;
    double      price;
};

std::vector<Product> catalogue = load_catalogue();

// Chain: keep category 2, extract name, drop first 5, show next 10
auto page = catalogue
    | std::views::filter([](const Product& p){ return p.category == 2; })
    | std::views::transform(&Product::name)    // member pointer projection
    | std::views::drop(5)
    | std::views::take(10);

for (const std::string& name : page) {
    std::println("{}", name);
}

Long pipelines compose left-to-right and are evaluated in a single pass over the source data. The compiler typically collapses the nested iterator calls into a tight loop with no heap allocation.

cpp
// C++20 β€” chaining transform into transform; both lambdas fused into one pass
auto result = std::views::iota(1, 101)
    | std::views::filter([](int n){ return n % 2 != 0; })   // odd numbers
    | std::views::transform([](int n){ return n * n; })     // square them
    | std::views::transform([](int n){ return n % 100; })   // mod 100
    | std::views::take(8);

// Iterate once; all four adaptors execute per element
for (int v : result) { std::print("{} ", v); }
// 1 9 25 49 69 89 9 29

Adaptor objects (e.g. std::views::filter(pred)) are partial applications; storing them in variables lets you build pipelines programmatically before attaching a source.

cpp
// C++20 β€” reusable adaptor partial application
auto positive_squares = std::views::filter([](int n){ return n > 0; })
                      | std::views::transform([](int n){ return n * n; });

std::vector<int> a = {-3, 1, -1, 4, 2};
std::vector<int> b = {5, -2, 3};

for (int v : a | positive_squares) { std::print("{} ", v); }   // 1 16 4
for (int v : b | positive_squares) { std::print("{} ", v); }   // 25 9

Owning Views vs. Borrowed Ranges

A view is a non-owning handle; the underlying data must outlive it. The std::ranges::borrowed_range concept tags types whose iterators remain valid even after the range object itself is destroyed β€” raw arrays, std::string_view, and std::span satisfy it; std::vector and std::string do not.

cpp
// C++20
#include <ranges>
#include <vector>
#include <span>

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

// views::all wraps an lvalue range in a ref_view β€” zero-overhead, non-owning
auto ref = std::views::all(v);          // type: std::ranges::ref_view<std::vector<int>>
static_assert(std::ranges::borrowed_range<decltype(ref)>);

// views::all on an rvalue moves the range into an owning_view (C++23)
auto owned = std::views::all(std::vector{1, 2, 3});  // owning_view β€” safe to store

The standard library uses borrowed_range to produce std::ranges::dangling for unsafe iterator returns, preventing use-after-free at compile time.

cpp
// C++20 β€” dangling iterator detection
auto it1 = std::ranges::find(v, 3);
// OK β€” v is an lvalue; decltype(it1) is std::vector<int>::iterator

auto it2 = std::ranges::find(std::vector{1, 2, 3}, 2);
// COMPILE ERROR β€” rvalue vector is not borrowed_range
// decltype(it2) is std::ranges::dangling; operator* is deleted

When building functions that return views, always accept the source by lvalue reference or accept a borrowed_range β€” never bind a view to a local container that will be destroyed on return.

cpp
// C++20 β€” safe: returned view borrows from caller-owned data
auto evens_of(std::span<const int> data) {
    return data | std::views::filter([](int n){ return n % 2 == 0; });
}
// span satisfies borrowed_range; the filter_view's iterators remain valid

std::ranges::to<> β€” Materialising Lazy Views (C++23)

std::ranges::to<C>() converts any range into a concrete container in a single readable expression. It handles nested range types (such as the subranges produced by views::split) correctly through the range constructor pathway, something a raw std::vector(r.begin(), r.end()) cannot do without extra work.

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

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

// Deduplicated sorted set β€” one expression
auto unique_sorted = v
    | std::views::transform([](int n){ return n; })
    | std::ranges::to<std::set<int>>();             // C++23

// Deduplication via sorted vector + unique_copy in one pipeline
auto deduped = std::views::iota(0, 10)
    | std::views::filter([](int n){ return n % 3 != 0; })
    | std::ranges::to<std::vector>();               // C++23 β€” deduces value_type

// Split a string into a vector of substrings
std::string csv = "foo,bar,baz";
auto parts = csv
    | std::views::split(',')
    | std::views::transform([](auto r){
        return std::string(r.begin(), r.end());
    })
    | std::ranges::to<std::vector<std::string>>();  // C++23

std::ranges::to also works as a free function: std::ranges::to<std::vector<int>>(view). Both forms reserve capacity when the source is a sized_range, avoiding redundant reallocations.

cpp
// C++23 β€” pipeline form vs. free-function form are equivalent
auto a = v | std::ranges::to<std::vector<int>>();
auto b = std::ranges::to<std::vector<int>>(v);

For C++20 targets without std::ranges::to, the idiomatic alternative remains std::vector<T>(r.begin(), r.end()) or std::ranges::copy into a back_inserter.

Custom Range Adaptors with view_interface

std::ranges::view_interface<D> is the CRTP base that turns any type with begin()/end() into a first-class view. It synthesises empty(), size(), front(), back(), operator[], and data() from the iterator pair, so a custom view inheriting it participates fully in the range adaptor ecosystem.

cpp
// C++20 β€” minimal custom view: every N-th element of another range
#include <ranges>
#include <iterator>

template<std::ranges::input_range R>
class stride_view : public std::ranges::view_interface<stride_view<R>> {
public:
    stride_view() = default;
    stride_view(R base, std::ptrdiff_t step)
        : base_(std::move(base)), step_(step) {}

    auto begin() {
        return iterator{std::ranges::begin(base_),
                        std::ranges::end(base_), step_};
    }
    auto end()   { return std::default_sentinel; }

private:
    R              base_{};
    std::ptrdiff_t step_{1};

    struct iterator {
        using value_type        = std::ranges::range_value_t<R>;
        using difference_type   = std::ptrdiff_t;
        using iterator_category = std::input_iterator_tag;

        std::ranges::iterator_t<R> cur, end_it;
        std::ptrdiff_t             step;

        value_type operator*() const { return *cur; }
        iterator& operator++() {
            for (std::ptrdiff_t i = 0; i < step && cur != end_it; ++i) ++cur;
            return *this;
        }
        iterator operator++(int) { auto tmp = *this; ++*this; return tmp; }
        bool operator==(std::default_sentinel_t) const { return cur == end_it; }
    };

    R              base_store_{};  // owned copy when R is a view
};

Inheriting view_interface is sufficient for most adaptor authorship. The standard library itself uses the same pattern β€” std::ranges::filter_view, std::ranges::transform_view, and friends all derive from it.

cpp
// C++20 β€” using the custom view directly and as a pipe source
std::vector<int> v = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};

stride_view sv{v, 3};
for (int x : sv) { std::print("{} ", x); }   // 0 3 6 9

// Works with standard algorithm overloads because it models input_range
auto n = std::ranges::count_if(sv, [](int x){ return x > 4; });  // 2

For production use, C++23 adds std::views::stride to the standard library, making custom stride views unnecessary β€” but the technique generalises to any domain-specific adaptor.

Range Algorithms with Projections

Projections are a second callable argument accepted by all std::ranges:: algorithms that replaces the need for custom comparators in the common case. A projection maps each element to the key the algorithm should operate on; the comparator then receives the projected keys, not the original elements.

cpp
// C++20
#include <ranges>
#include <algorithm>
#include <vector>
#include <string>

struct Student {
    std::string name;
    int         grade;
    double      gpa;
};

std::vector<Student> roster = load_roster();

// Sort by GPA ascending β€” member pointer projection, no lambda comparator
std::ranges::sort(roster, {}, &Student::gpa);

// Sort by GPA descending β€” comparator + projection compose cleanly
std::ranges::sort(roster, std::greater{}, &Student::gpa);

// Sort by name length, then alphabetically β€” two-level sort using projection
std::ranges::sort(roster, {}, [](const Student& s){ return s.name.size(); });

Projections compose with every constrained algorithm, not just sort. std::ranges::find, std::ranges::min_element, std::ranges::partition, std::ranges::unique, and std::ranges::lower_bound all accept the same projection argument.

cpp
// C++20 β€” projections across a variety of algorithms
// Find the student with the highest GPA
auto top = std::ranges::max_element(roster, {}, &Student::gpa);

// Partition: honour roll (gpa >= 3.5) first
auto [honour, rest] = std::ranges::partition(roster,
    [](double g){ return g >= 3.5; }, &Student::gpa);

// Binary search on a pre-sorted range β€” project to the sort key
std::ranges::sort(roster, {}, &Student::name);
bool found = std::ranges::binary_search(roster, "Alice", {}, &Student::name);

// Count students in grade 11
auto n11 = std::ranges::count(roster, 11, &Student::grade);

A projection can be any callable: a free function, a lambda, a std::function, or (most efficiently for struct fields) a member pointer. Member pointer projections have zero overhead β€” they compile to a single field access with no indirection.

cpp
// C++20 β€” projections in two-range algorithms
std::vector<Student> transfer_students = load_transfers();

// Check if every transfer student name already appears in roster
bool all_present = std::ranges::includes(
    roster, transfer_students, {}, &Student::name, &Student::name
);
// Each algorithm that accepts two ranges accepts two independent projections,
// one per range β€” the comparator sees (proj1(a), proj2(b)).

See Also

  • Concepts β€” the constraint system underlying range requirements
  • Coroutines β€” C++20 lazy generators; coroutines and ranges compose naturally
  • Structured Bindings β€” used with views::zip and views::enumerate
  • Lambda Expressions β€” the primary way to pass predicates and projections